Compare commits

..

61 Commits

Author SHA1 Message Date
7d4eef7316 chore(gui): 版本号 3.0.2(合规清理:移除私有协议适配器与渠道插件) 2026-10-27 09:23:48 +08:00
8578696cc1 chore!: 移除厂商私有协议适配器与渠道业务插件(合规清理)
G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类
能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id,
属于绕过客户端校验获取额度,风险高于普通协议适配。

移除(93 个文件):
- 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode
- 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针)
- 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档

同时修掉的既有问题:
- **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处)
  随 channels.lua 一起消失。
- `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提
  把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约
  冲突。现在部署脚本只同步适配器与二进制/配置。
- 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已
  移除,该测试此前已失效)。

保留(公开协议):openai / anthropic / gemini / deepseek / github / groq /
mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。

验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、
gateway 122.8s、e2e 9.0s 全绿。
2026-10-27 09:23:24 +08:00
06a74375d8 chore(gui): 版本号 3.0.1(与内核同版本)
v3.0.1 修的是发布包缺陷(unit 路径、core-dist 无 nfpm、桌面包内嵌核心未 strip),
桌面端版本号必须跟内核一致:升级判断与插件目录维护都读这个字段。
2026-10-27 00:50:24 +08:00
de9080e06c fix(gui-win): docker 内联编译也加 -s -w
上一提交只改了 prepare-core.js(npm run core:win 路径),而 docker 构建走的是
dist-win-docker.sh 里的内联 go build,产物里的内嵌核心仍是 17.8MB 带调试符号。

两处必须同步,否则"用哪种方式构建"会决定包大小:现在 Linux 与 Windows 的内嵌
核心都剥符号。
2026-10-27 00:47:27 +08:00
80e79f8dc7 fix(gui): Windows 构建链改用 modelrouter 命名 + 内嵌核心 strip
改名在桌面打包链上漏了两处,都是"构建能过、产物不可用"的类型:

1. `dist-win-docker.sh` 仍交叉编译到 `bin/llmsproxy.exe` 并构建
   `./cmd/llmsproxy`,而 package.json 的 extraResources 已指向
   `bin/modelrouter.exe` —— Windows 包装完会缺内嵌核心。
   同时把 npmmirror 的 electron / electron-builder-binaries 镜像传进容器:
   首次构建需在容器内下载数百 MB,直连会卡满 600s 请求超时(Linux 侧已实测)。

2. `prepare-core.js` 只带 `-trimpath`,内嵌核心保留调试符号(14.4MB)。
   补 `-ldflags "-s -w"`,与 `packaging/core-dist.sh` 一致,各平台安装包少约 4MB。
2026-10-27 00:45:26 +08:00
b6bbff19e9 fix(packaging): 包内 unit 指向 /usr/bin;core-dist 支持无 nfpm 打包
两个都是发布链上的真缺陷,构建 v3.0.0 产物时才暴露:

1. **unit 与包内二进制路径不一致**:deb/rpm 把二进制装到 `/usr/bin/modelrouter`,
   而 `modelrouter.service` 的 `ExecStart` 写 `/usr/local/bin/modelrouter`。
   装包后 `systemctl start` 只会给 203/EXEC,看起来像"装好了但起不来"。
   - 包内 unit 改 `/usr/bin/modelrouter`(FHS,装包即用)
   - 源码部署仍用 `/usr/local/bin`,`deploy.sh` 增加校验:unit 的 ExecStart
     指向不存在的文件时**中止**并说明两种布局的区别
   - docs/deployment.md 同步说明

2. **core-dist 硬依赖 nfpm**:构建机离线(GOPROXY 不可达)时装不上 nfpm,
   发布直接被堵死。现在 nfpm 优先,缺失时回退 dpkg-deb + rpmbuild,
   两者产出同一套文件布局,并继续由末尾的 archive-content 门禁校验。
   - rpm 回退踩到的坑:postinst/prerm 末尾无换行时,`%preun` 会粘在
     `exit 0` 后面,整个 `%files` 段被吞,rpmbuild 报
     "Installed (but unpackaged) file(s) found"。
   - `%dir /etc/modelrouter` 要求 buildroot 里存在该目录,否则
     "Directory not found"。

验证:`./packaging/core-dist.sh 3.0.0` 产出 deb(4220KB)/rpm(4229KB)/tar(4145KB);
包内 unit `ExecStart=/usr/bin/modelrouter`,`systemd-analyze verify --root` 通过;
deb/rpm 均不含 `plugins/`,adapters=14。
2026-10-27 00:32:14 +08:00
d5bbb0eca5 chore(gui): 版本号 3.0.0(与内核同版本)
改名为 breaking:go module 路径、二进制名、`/etc` 配置目录、`LLMSPROXY_*`
环境变量全部变更,已部署实例必须跑 `packaging/migrate-to-modelrouter.sh`。
因此内核与桌面端一起升到 3.0.0,而不是继续在 v2.0.x 上打 patch。

`Makefile` 的 VERSION 来自 `git describe`,无需另改。
2026-10-27 00:07:07 +08:00
9cb6254166 docs: 补入 2026-10-19 交接文档
记录当时的 Git 状态、渠道适配器进度、以及"provider 回归测试存在未完成编辑"
这一未决项。作为历史记录保留,不参与构建。
2026-10-27 00:06:41 +08:00
4514270e21 fix(opencodezen): UA 升到 1.19.4 并补回 x-opencode-session-id
- 上游按客户端指纹判定免费层资格:CLI 1.18.29(4 个 x-opencode-* 头)
  稳定 429;CLI 1.19.4(5 个头,含 session-id)200 且任务真实完成。
- 适配器的 UA 与头集合必须**同步**升级:只改 UA 声称的版本、不发对应头,
  指纹反而更不一致,实测重新落回 429。
- 头形状按官方 CLI 抓包校正;`x-opencode-session-id` 与 `x-opencode-session`
  同值,此前被误删。
- live 测试新增 `gatewayBinary()`:优先 `MODELROUTER_BIN`,其次
  `/usr/local/bin/modelrouter`,回退改名前的 `/usr/local/bin/llmsproxy`。

注:本提交是改名/插件整改之外的独立修复,先于 v3.0.0 收口。
2026-10-27 00:06:41 +08:00
ef95bc45d0 docs(provider): 中性化传输代理注释里的第三方客户端表述
`internal/provider/provider.go` 的两处注释用第三方客户端名作代理示例
(`clash on 127.0.0.1:7890`)。改成中性的「a local HTTP proxy on
127.0.0.1:7890」,语义不变:仍然是在说明 per-source `proxy_url` 的用途。

上一轮中性化只做了大小写敏感的 `Clash` 扫描,漏掉了这两处小写;
本次改用 `-i` 全仓复查,源码/文档/打包内已无该表述(`clashing`、
`diverge` 这类同形词不属此列)。
2026-10-27 00:00:19 +08:00
f7e926ccb7 docs: README 拆分为主题文档 + 中性化竞品表述
README 从 642 行降到 131 行,只保留简介、核心能力、最小快速开始、文档索引、
改名说明、目录结构与测试入口。其余内容迁到 docs/,中英文索引一一对应:

  docs/features.md       实现与完整特性清单(原 README 的"实现/特性")
  docs/configuration.md  配置文件、自动探测模型、多密钥与配额
  docs/routing.md        模型路由、AUTO 链、冷却与半冷却、源/适配器、源模板
  docs/performance.md    实测内存占用与调优、thinking 预算
  docs/webui.md          WebUI 使用
  docs/adapters.md       Lua 适配器协议与内置适配器清单
  docs/gui.md            桌面端 Headless/Desktop 两种产物与构建
  docs/development.md    目录职责、开发协作、测试
  docs/deployment.md     部署、发行包、目录权限、llmsproxy 迁移与验证清单

中性化:README / README_EN / GUI 注释里的 `Clash Verge` 类比全部改为
「桌面壳 / desktop shell」,仓库内(源码与文档)已无该表述。
2026-10-26 22:28:24 +08:00
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
de7553eaa2 feat(lua)!: 插件只通过 modelrouter SDK 与内核交互,且不再随内核安装
- 新增唯一公开接口表 `modelrouter`(sdk_version=1.0):http / secrets /
  kernel / adapters / plugin_dir。成员与内部桥是**同一对象**而非副本 ——
  复制会让凭据保险库分叉(插件写 A、内核读 B)。
- `llmsproxy` 与 `__llmsproxy_*` 降为同对象兼容别名,只为已装插件继续可用。
  仓库内 4 个插件(channels / billing / quota / usage)全部改用
  `modelrouter.*`:此前它们直接依赖内核私有全局,内核无法在不炸插件的前提下
  改实现(重命名、加守卫、换实现都算破坏性变更)。
- 插件不再由网关安装:`SeedBundled()` 变彻底 no-op,`core.New` 不再调用;
  `ReadBundledPlugin` 改为按源文件定位(脱离 cwd),只服务测试与开发工具。
- 桌面端删除 `BUILTIN_PLUGINS` 与 `syncBundledPluginsOnUpgrade`:升级不再
  删除、改名或覆盖用户插件文件。插件是运维资产,不是内核资源。
- `Builtin` 不再靠"源码与仓库示例逐字节相同"推断,插件一律视为外部文件。
- 判据:SDK 存在/版本/形状、别名与内部桥同一张表、适配器 VM 拿不到 SDK、
  经 SDK 真实写读凭据并驱动内核动作、GUI 升级不动插件文件、release 包不含插件。

说明:channels.lua / quota.lua 同时带有此前的渠道与额度改动(含 OpenCode 免费池
监控),与 SDK 改名同处一个文件,无法文件级拆分,故一并纳入。
2026-10-26 22:27:12 +08:00
79bc55cfbe fix(billing): 插件 HTTP 路由按操作拆路径 + 正确解析请求体
- 内核插件路由冲突仲裁按**路径**判重(不看 method),同一路径挂 4 个 method 会被
  当成 4 个插件抢同一路径 ⇒ 规则 API 不可用。拆成 /rules(GET)、/rules/replace
  (PUT)、/rules/add(POST)、/rules/remove(DELETE)。
- 插件 HTTP 分发交给 handler 的是**整个请求表**(body 是原始字符串),而直调导出
  拿到的是参数表;此前 PUT 把请求信封当成规则写进了 billing.json,规则全丢、价格表
  清空。新增 reqData() 统一解出 JSON 载荷。
- 生产复验:GET 返回 4 条规则;PUT 后 billing.json 保持规则形状;localzen(free)
  请求不计入 unpriced,codearts 未列价模型计入(预期收紧)。
2026-10-20 18:59:16 +08:00
4b9fb7b056 chore(gui): 版本号 2.0.49(billing 完全沉入插件) 2026-10-20 18:57:27 +08:00
7941b4110a refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持
按维护者要求(issue #1 回复方向):内核不处理任何 billing 特有逻辑;插件有完整
OS 权限,自己保存配置文件。据此不再保留任何"内核 ↔ billing 插件"的专用对接。

## 内核(删除)
- `internal/billing`(Go 编译器 + 测试):删除;
- `internal/core` 的 billing 接口/降级应用/规则加载:删除(含 `billingPath`);
- `internal/gateway` 的 `/api/plugins/billing/rules` 端点与测试:删除;
- `config` 的 `BillingDSL/Profile/Rule/Token/Peak/LongContext` 与校验、
  `billing:`/`billing_file:` 配置段:删除。系统配置里不再有 billing 段。
内核只保留所有插件通用的能力:加载 .lua、插件状态持久化、插件目录
(`__llmsproxy_plugin_dir`)、`plugin.http` 路由、插件可用的 io/os。

## 插件(billing.lua)
- 规则文件移到**插件自己的目录**:`<plugin_dir>/billing.json`,由插件用 io 自己
  读写(tmp + rename 原子替换);加载插件时自己读文件并编译;
- 规则以**源名** `source` 为键(`url:"*"` 仅表示全局模型价目表):URL→源名映射
  属于内核配置,插件不为它和内核对接;编辑页在浏览器里取 `/api/sources` 选源名;
- 规则编辑 API 改为插件自己的 `/api/plugins/http/billing/rules`
  (GET/PUT/POST/DELETE,roles=admin),写盘也在插件内完成;
- 计费账目仍在插件状态文件(内核通用持久化)`<plugin_dir>/.billing.state.json`;
- `priceFor` 落地 profile 级 `default` 策略与显式 `mode=unpriced` 的优先级。

## 迁移
生产已迁移:`config.yaml` 的 `billing:` 规则转为 `plugins/billing.json`(按源名
重写),并从系统配置中移除;规则文件与系统配置从此各自独立保存。

## 判据
- `internal/lua/billing_rules_test.go`:插件自写 billing.json 并重启恢复、
  free/subscription/token/通配符编译语义、1e6+1e6 token = 0.75 端到端、
  非法规则被拒且不覆盖旧文件、reasoning/long_context/peak;
- `internal/lua/billing_test.go`:profile default 策略三态 + 显式 unpriced 优先;
- `internal/lua/billing_ui_test.go`:规则编辑页仍挂在 billing 页内、列宽/列数守卫。
2026-10-20 18:57:23 +08:00
5ccf068db3 chore(gui): 版本号 2.0.48(与内核同版本) 2026-10-20 18:33:29 +08:00
b4c5f0f0fc refactor(billing): 计费语义下沉到 Lua(内核只解 YAML/传数据)+ 采纳 PR #2 语义
按 issue #1 的修复方向(维护者评论):内核不再持有任何计费语义,billing 逻辑
全部在 `plugins/billing.lua`;内核只负责解 config.yaml、校验 schema、把
profile + 源 base_url 作为**数据**交给插件,再把插件算出的价目通过 `prices`
侧车注入。计费账目仍持久化在插件状态文件(`plugin_dir/.billing.state.json`)。

## 变更
- **删除** `internal/billing`(compile.go 359 行 + 其 Go 测试);
- 新增 `core.compileBillingPrices`:把 profile/sources JSON 化后调用插件导出
  `compile_prices`,返回价目表 + 未匹配规则清单(供错误日志点名 URL);
- `applyBillingDSL` / 规则编辑器改为走同一个插件编译入口(编辑器 lenient,
  半成品规则可保存、可报告);
- `billing.lua` 新增 `compile_prices`:URL 归一匹配、首条命中优先、
  free/subscription/unpriced/token、USD/M→per-token、peak 窗口、
  long_context 边际价、reasoning 可选价、default 策略、未匹配清单。
  错误通过载荷 `error` 字段回传(插件导出第二返回值通道不可靠)。

## 与 PR #2(valuelesser)合并后的语义
- **token 规则不产生任何源条目**:未列价的模型落到 unpriced 统计(可见),
  不再"静默计 $0"。比 issue 正文里的 `{per_request:0}` 标记更收口;
- `default: free` 由**编译期**给未匹配源写 `{per_request:0}` 标记;
  `unpriced`/缺省不写。删除无人读取的 `default_mode` 死键;
- 保留 `unmatched_rules` 清单(只用于降级应用时点名过期 URL)。

## 判据(原 Go 单测平移到 Lua + PR #2 的用例)
- `internal/lua/billing_compile_test.go`:通配符不标记源、token 规则无源条目、
  任意源条目不得携带 token 价(根因直防)、free/subscription 保留有意零价、
  reasoning 缺席、long_context 三种形态、peak 形状、URL 归一+首条优先、
  lenient 未匹配清单、硬错误 5 例、default 两态;
- `internal/lua/billing_test.go`:编译输出驱动真实 priceFor 的 A/B 用例
  (旧形状 cost=0 / 新形状 0.75 且 unpriced=0)、default 编译期语义 e2e;
- `internal/gateway`:token 规则断言"无源条目"、模型价 per-token 量级;
- `internal/core`:过期规则 URL 降级应用(有效规则仍生效)。
2026-10-20 18:33:26 +08:00
442fa5e3f6 chore(gui): 版本号 2.0.47(与内核同版本) 2026-10-20 18:05:19 +08:00
0caa37ea6a fix(billing): 过期规则 URL 不再让整个 profile 失效(降级应用 + 点名 URL)
生产现网发现(issue #1 排查):billing.rules 里一条指向
https://opencode.ai/inference/v1 的规则,在该源 base_url 变成
.../inference/openai/v1 之后匹配不到任何源;严格编译因此让**整个** profile
不生效 —— 账单恒 0、unpriced_reqs 涨到 3.7 万,唯一痕迹是一行启动日志。

- 严格编译失败时降级为 lenient:应用能匹配的规则,其余按 profile default;
- 日志用 ERROR 点名**具体哪条 URL** 写错(新增 billing.UnmatchedRuleURLs),
  并说明未匹配流量的记账去向;
- 判据 TestBillingStaleRuleURLStillAppliesOtherRules:一条过期规则 + 一条有效
  规则 ⇒ 有效规则必须仍然生效。
2026-10-20 18:04:43 +08:00
33cb2bdb06 fix(billing): 源层零价不再覆盖模型价(issue #1)+ default_mode 生效 + 文档/判据补齐
GitCode issue #1:任何按 `mode: token` 且 URL 非通配符的规则定价的源,计费恒为 0,
且 `unpriced_reqs` 也不增长 —— 完全静默。

## 真因
- `compile.go` 给这类规则写源条目 `{prompt:0, completion:0, per_request:0}`,
  本意只是"标记该源已定价"(同源未列入价目表的模型不算 unpriced);
- `billing.lua` 的 priceFor() 源层在模型层之后、且 `sp.prompt ~= nil` 即覆盖;
- 两者叠加 ⇒ 模型价被清成 0,而 `out.priced` 仍为 true ⇒ 连未定价计数都不涨。

## 修法
1. `compile.go`:token 规则的源条目只写 `{per_request: 0}`(只是标记,不参与 token
   计价)。`free` / `subscription` 保留全零(那里 0 本身就是语义)。
2. `billing.lua`:
   - `priceFor()` 的 `out` 补 `local`(此前是 VM 级全局变量,跨请求残留);
   - 实现 `profiles[].default` 的 `free|unpriced` 语义:`free` = 刻意免费(不进
     unpriced),`unpriced`(默认)= 未定价债务。此前 compile.go 写 `default_mode`
     但**无人读**,两种取值运行时完全等价。
3. `core.go`:应用 profile 时把 `unmatched_sources` 打进日志(WARNING 列出没有
   规则命中的源)。该字段此前只写不读,是第二个静默少报点。
4. `docs/plugins.md`:token 价优先级改为与实际实现一致的
   `keys > sources > models > default`,并写明"标记已定价只能用 per_request:0"。

## 判据(都先红后绿)
- `internal/billing`:token 规则的源条目**不含** prompt/completion;free/subscription
  仍保留显式 0。
- `internal/lua`:同源同模型下旧形状 cost=0(缺陷本身)、新形状 cost=0.75 且
  unpriced_reqs=0;未列入价目表的模型仍算"已定价";default_mode 三态。
- `internal/gateway`:`billing_rules_api_test` 的单位断言原本读
  `cc["models"]`(恒 false,整块被跳过——从未执行过),改为 `prices["models"]`,
  并新增"源条目不得带 token 价"断言。

## 顺带修掉一个"永不执行"的判据
`discover_mutate_test.go` 的变异锚点写成 6 个空格,而 `discover.go` 已是 2 个空格
⇒ 该变异每次都 `anchor not found`,变异 campaign 长期红着。修正锚点后,
"嵌套 metadata 不再读取"这条变异真的被执行并被测试 KILL。
2026-10-20 17:46:52 +08:00
0ae7d2c8be test(retry): 钉住声明式重试判据与流式握手重试语义
- config:ShouldRetry/DelayDuration 表驱动(nil/次数=1/状态不匹配/body 不匹配/
  空子串/多信号/非法间隔)
- provider:命中策略就重试直到 200、body 不匹配**不许**重试(400 也常用于真错误)、
  流式只重试握手(首帧之后不重试,避免重复输出)
2026-10-20 10:45:56 +08:00
02c01582f1 chore(gui): 版本号 2.0.46(与内核同版本) 2026-10-20 10:38:07 +08:00
6454b094bc fix(codearts): 上游限流改为可声明重试(内核通用能力)+ 会话/连接两处真因
用户连续报「codeart 还是不可用 / empty stream」。把上游原始响应打出来逐条对齐,
三个症状同源,且都不是"模型坏了":

| 场景 | 上游实际返回 |
|---|---|
| 流式、短时间连续请求 | HTTP **200** + 只带 error 的帧 ⇒ 内核判 `empty stream` |
| 非流式、同一条件 | HTTP 400 `TM.00001041: 并发会话数已达上限(3个)` |
| 突发请求 | HTTP 429 `TPM limit … 500000 tokens per minute` |

实测:最后一个请求结束后约 **44~60 秒**释放;同一账号连续请求 3~4 次即占满。
参考实现(示例插件 llm-adapter.ts)的做法是**每 10s 轮询后重试 chat**(最多 30
分钟),所以用户那边"看起来没事"。

## 我们的改法(内核只做通用机制,厂商知识留在插件)
1. **内核新增声明式重试策略** `source.retry`(纯数据:状态码列表 + 响应体子串 +
   间隔 + 次数)。内核不认识任何厂商;命中即在同一网关请求内每 10s 重试一次。
   流式只在**握手阶段**重试(首帧尚未下发,不会重复输出),非流式整体重试。
2. **插件为 codearts 声明**:400/429 + 命中 `TM.00001041` / `TPM limit` /
   `并发会话数已达上限` → 10s × 最多 18 次(≈3 分钟)。
3. **适配器补两个会话细节**:`Connection: close`(keep-alive 空闲连接会被上游
   计入并发会话)与稳定 `Session-Id`(同一账号恒定,避免每次请求新建会话)。
4. 错误文案补可行动提示(并发会话上限 / TPM 限流,约 1 分钟释放)。

## 验证(生产)
- 连续 6 次**流式**:6/6 成功(撞上限时自动等 10s 再试,客户端只感觉慢):
  `+0s / +12s / +34s / +86s / +100s / +144s`
- 连续 5 次**非流式**:5/5 成功(`+0 / +11 / +20 / +28 / +38s`)
- 修复前:第 4 个请求起必然 TM.00001041 / empty stream。

> 注:`TM.00001005 无正常可用套餐` 属于账号权益问题(不重试、如实报出),
> 与限流要分开处理。
2026-10-20 10:38:00 +08:00
771b95a133 fix(channels): admit_functions 落到 effectiveModels(解析器不承担产品策略)
上一提交把「本适配器会发的通道」过滤写进了 trae 的目录解析器,导致两个解析器判据
回归:同模型在 solo_agent(有思考档位)与 solo_work_lite(无档位)同时出现时,
整组 solo_agent 被丢弃 ⇒ 档位信息丢光,模型显示"未提供推理等级"。

正确分层:
- **解析器**只负责把目录如实解出来(含跨通道元数据合并、白名单准入);
- **产品策略**(只暴露适配器能发的通道)放到 effectiveModels,作用在对外
  暴露的 id 列表上。

实现要点:合并会选"更优"的那条(往往不在我们要发的通道里),所以判据不能看
合并后条目的 chan —— 解析时额外记 `chans`(该 id 在哪些通道出现过),策略按
chans 求交集。夹具同步调整:硬性过滤的条目放进 solo_work_lite(生产里适配器
恒发这条通道),inline_chat 仍用于白名单判据。
2026-10-20 10:12:51 +08:00
441de434f5 chore(gui): 版本号 2.0.45(与内核同版本) 2026-10-20 09:54:06 +08:00
9feef8f4cb fix(channels): 渠道卡片 logged_in 改成看账号池(此前所有池接入的平台都显示未登录)
## 真因:logged_in 判的是旧单账号键
用户报「codearts 还是不可用」。查下来渠道**本身是通的**(9 个模型逐个实测都返回
内容),不可用的是**UI 的登录态判断**:

`list_platforms` 写的是 `credentialOf("channels:" .. platform)` —— 那是**旧版
单账号**的保险库键。现网凭据键是 `channels:<platform>:<accountId>`(一平台多
账号),于是**所有**通过账号池接入的平台都被判成"未登录":

    修复前(生产):trae=False minimax=False codearts=False qoder=False zcode=False
                    (只有 buddy=True —— 它还留着一条旧格式凭据)
    修复后:          trae=True  minimax=True  codearts=True qoder=True  zcode=True

UI 据此把渠道卡片渲染成"未接入",用户看到的就是"渠道不可用"。底层模型完全能用 ——
这是最容易被误判成"上游挂了"的一类假信号。

## 修法
- `list_platforms`:logged_in 改为遍历账号池(`poolList` + 凭据存在 + enabled≠false),
  旧单账号仅作兜底;顺带回 `account_id` / `accounts` 计数,让 UI 能区分同平台多账号;
- `status_page`:`accounts` 同样合并账号池(此前只回旧表,状态页里池接入的平台
  是空的 —— 同一根因的第二个出口);
- 新增 `__platforms_probe`(`list_platforms` 是 HTTP 路由处理器、不在 exports 里,
  判据走探针,与仓库既有做法一致);
- 回归判据 `TestPlatformsLoggedInFromAccountPool`:池里有凭据账号 ⇒ logged_in=true,
  且 account_id / account / accounts 回池数据。

## 顺带核实(不是代码缺陷)
- codearts 目录里 4 个模型(`deepseek-v4-flash-0731` / `glm-5.3-flash` /
  `deepseek-v4-pro-0813` / `CodeArts-Pangu`)实测上游 404
  `InferHub.002002009.404: The model is not registered` ⇒ 之前被手工停用是对的,
  已按死模型流程收口(不再回到可选列表);
- `GLM-5.3` 同样 404,已标失效并自动从源里剔除(日志:
  「模型已失效,已标记 codearts/GLM-5.3」);
- `deepseek-v4-flash` 早前的 429 是 TPM 限流(我自己连续压测造成),现已恢复 OK。
- 最终 codearts:**9 个模型,9/9 实测返回内容**。
2026-10-20 09:54:06 +08:00
b5c89fd3d7 chore(gui): 版本号 2.0.44(与内核同版本,桌面插件刷新判据依赖它) 2026-10-20 09:39:49 +08:00
7f193a997d fix(channels): trae 目录用错头族——IDE 端点必须发 IDE 头(此前恒回 2001)
## 真因:两套头混用
用户报「trae 还是无法从上游拉取,示例插件都可以」。用探针把**原始响应**打出来
才看清:目录端点 HTTP **200**,但 22 个 function 全部
`error_code 2001 "Config detail for function: X not found"` —— 于是远端目录恒空,
插件只能退回兜底表,用户侧表现成「模型不正确 / 渠道不可用」。

根因不是网络、不是账号、不是 body:是**头族用错了**。
- `traeCheckinHeaders` 面向 `api.trae.cn` 的签到/积分:VSCode UA + `X-Market-*`
  + `X-Lscbd-*` + `Vscode-Sessionid` + **派生**的 market device id;
- `traeIdeHeaders` 面向 `trae-api-cn.mchost.guru` 的目录/对话:`Trae/0.1.52` UA
  + `X-App-Id` + `X-Ide-Version(-Code)` + `Request-Traffic-Type` +
  **凭据里登录时的** `device_id`/`machine_id`。

目录刷新当时复用的是签到头(注释还写着"复用更安全"),发到 IDE 端点就被逐
function 判 2001。用 IDE 头直发同一端点:**HTTP 200 + 2.6 MB**、每个 function
都带 `config_info_list`。

修法(全部在插件内,内核零改动):
1. 新增 `traeIdeHeaders()`,与签到头**明确分开**,注释写清两套头的适用面;
2. 目录刷新(`sign="trae_solo"`)改用它,`Accept: application/json`;
3. 加判据 `__trae_headers_probe` + `TestTraeHeadersKeptSeparate`:钉死两套头的
   键集合互不越界(IDE 头必须有 X-App-Id/X-Machine-Id、不得有 X-Market-*;
   签到头反之)。这类"都能跑、但跑错了"的错只能靠契约钉死。

## 目录拉通后暴露的第二个问题:目录≠可调用
拿到真实目录(22 function / 93 条去重模型)后逐个实测,发现**同一个
config_name 在不同 function 里可用性不同**,而本插件适配器恒发
`solo_work_lite`:
- `kimi-k2.8-preview` / `deepseek-V3` / `glm-4.7` / `code-review-judge` /
  `multimodal_image_kimi-k2.5` → HTTP 200 但 **content 为空**;
- `sagitta` → 自称 Claude、`aquila` → 自称 ChatGPT、`custom_model_gpt-5` →
  自称 GPT-4 系列 ⇒ 这些是**未知 config 时上游悄悄回落默认模型** ——
  这正是"模型不正确"的真实来源。

于是补两道准入(都是**声明式**平台数据,不写死逻辑):
- `models_remote.admit_functions = { "solo_work_lite" }`:只收本适配器**会发**
  的那条通道里的模型(白名单只说"该通道在本端点可调用",不等于"适配器会发");
- `models_fallback_no_supplement = true`:远端目录可用时**不再补兜底表**
  (兜底表里 sagitta/aquila/glm-5/DeepSeek-V4-Flash 等在上游已无对应配置)。

结果:trae 可选模型 = **20 个**(glm-5.3 / glm-5.3-flash / glm-5.3-flashx /
glm-5.2 / kimi-k3 / kimi-k2.7-code / kimi-k2.6 / minimax-m3 / minimax-m3.1-flash /
qwen3.8-max / qwen-3.7-plus / step-5-preview / mimo-v2.6-{flash,pro} /
deepseek-v4.1-flash / DeepSeek-V4-{Flash,Pro}-Official / Doubao-Seed-2.1-{Pro,Turbo,Evolving}),
**20/20 逐个实测返回非空内容**。

## 回归测试
- `TestTraeHeadersKeptSeparate`(新):两套头键集合互斥(不联网即可复现该类回归)
- `TestTraeCustomModelSlotsAreNotOffered` / `TestTraeSourceOmitsCustomModelSlots`
  (上一版):custom_model_* 槽位不进 UI 也不进上游源
- 死模型判据:opencode 的 `has been deprecated` 文案阳/阴性(上一版)
2026-10-20 09:38:39 +08:00
055705c134 chore(gui): 版本号 2.0.43(与内核同版本;桌面插件刷新判据按 app 版本,必须同步)
桌面壳的「内置插件升级刷新」按 app.getVersion() 打戳:GUI 版本号若与内核脱节,
升级后 channels.lua 不会重新播种,渠道修复又会被静默留在旧版。
2026-10-20 09:22:16 +08:00
401a7ead27 fix(channels): trae 模型表去掉不可调用槽位 + opencode 免费模型按实测收敛
## ① trae:模型选择器里那几个 `custom_model_*` 是**槽位不是模型**
用户报「trae 的模型不正确 / 渠道不可用」。逐个实测(生产):
`custom_model_kimi` / `custom_model_claude` / `custom_model_1M` 返回 **空
content**(`custom_model_gpt-5` 也答非所问),而真实模型
(glm-5.2 → Z.ai GLM、kimi-k3 → Moonshot、minimax-m3 → MiniMax、
Doubao-Seed-2.1-Pro → 字节)全部正常 —— 所以渠道是通的,问题在**表**。

根因:`custom_model_*` 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威
判据是 `is_custom_model: true`。插件自己的 `trae_batch` 解析器早已按这条
**准入**剔除,但**兜底表**把它们当普通模型列了出来;而远端目录在部分账号
下恒空(企业租户账号实测:22 个 function 全回
`error_code 2001 Config detail for function: X not found`),用户只能看到
兜底表 ⇒ 选中槽位后上游回空 ⇒ 「模型坏了」。

修法(**纯插件**,内核零改动):
- 新增 `models_unavailable` 一档:声明「本协议下不可调用」的条目,
  **既不进 UI 选择器,也不进上游源**(与 `models_hidden` 区别:后者只是
  不进 UI,内部子 agent 仍可显式调用);
- trae 的 11 个 `custom_model_*` 移入该档;把 4 个内部子 agent 留在
  `models_hidden`(语义不变);
- 生产验证:trae 源/选择器从 28 → **17**,逐个实测 17 个全部返回非空内容。

## ② 远端目录「空」要能区分原因
`refresh_models` 此前只会报「远端返回空目录」,把两种完全不同的故障混为一谈:
网络/HTTP 故障 vs 服务端 200 但账号侧没有可下发配置。
现在后者如实报:「远端 200 但无模型配置(账号侧未下发 function config,
企业/租户账号需管理员开启,实测 error_code 2001)」——运维据此知道该找
租户管理员,而不是反复重试网络。
另加 `__trae_catalog_probe`(诊断探针):原样回传目录端点的 status/body 前
N 字节/解析条数,并在 2001 现场定位时立了大功(这类"上游 200 却空目录"没有
原始响应只能瞎猜)。

## ③ opencode / localzen:免费模型按**实测**收敛
用户报「localzen 也不能用了」。逐项实测(生产):
- `space-bunny-free` → **410** `Model space-bunny-free has been deprecated.
  Use minimax-m3.1-flash-free instead.`(仍在本地源里 ⇒ 选中必失败)
- `jev-1.13-free` → 400 `Model does not support this protocol.`(非 OpenAI
  chat 协议,本端点永远调不通)
- `beam-free` → 200 但 content 空、无 finish_reason
- 其余 7 个(mimo / nemotron-super-vl / nemotron-lightning / longcat /
  deepseek-v4.1 / glm-5.3-flash / minimax-m3.1-flash)全部正常返回内容

修法:
- `DEAD_GONE_PATTERNS` 补 opencode 的下架文案
  (`Model … has been deprecated`);配阴/阳性判据(参数/字段被弃用**不算**);
- opencode 平台把 jev-1.13-free / beam-free 列入 `models_unavailable`,
  并从兜底表与 models_free 里移除;
- 生产 `localzen` 源(手写源,插件不管)按实测刷新为 7 个可用免费模型,
  逐个验证返回内容。

## 纪律
渠道逻辑全部留在插件里(channels.lua / 插件自装适配器);内核这轮**没有**
新增任何厂商语义 —— 上一轮加进内核的只有通用的 `adapter.hidden` 机制。
2026-10-20 09:13:56 +08:00
411503aea5 fix(gui): 桌面版真正加载渠道插件 + 外链一律交给默认浏览器
## ① 桌面版此前**完全没有插件系统**(渠道页根本不存在)
GUI 内嵌内核的 config.yaml 由 main.js 生成,唯独漏了 `plugin_dir` ——
而内核刻意不给它默认值(运维显式开启)。后果:桌面版 `plugin_dir: ""`,
`/api/plugins` 返回空列表,channels 插件(多平台账号登录、凭据保管、自动
领取)在桌面端**完全不可见**。实测复现:同一份配置去掉 plugin_dir 就是
`plugins: []`,加上就有 billing/channels/quota/usage 四个。

修法:
- 新 profile 直接写 `plugin_dir: <profile>/plugins`;
- **老 profile 回填**(用户已有 config.yaml,不写就只有老用户永远没有插件)——
  新增 `ensurePluginDirConfigured()`,只在缺少该键时追加,其余内容不动;
- 插件树由内核 SeedBundled 播种(按文件补齐,用户改过的副本优先保留)。

## ② 升级后渠道修复**下发不到桌面用户**
内核播种规则是「文件缺失才写」,所以桌面用户升级 GUI 后 `channels.lua`
仍是旧版 —— 我们发的渠道修复对桌面用户静默失效。桌面版必须自己处理:
按 app 版本打戳(`.plugin-seed-version`),版本变化时删掉 4 个内置插件文件
让新内核重新播种,旧副本留 `.prev-<旧版本>` 可人工恢复;用户自建插件不碰。
逻辑抽到 `cmd/gui/plugin-sync.js`(无 Electron 依赖),配 6 条 node 单测。

## ③ 登录一律拉起默认浏览器(三条路径都覆盖)
桌面版的内容区是加载内核 WebUI 的 iframe,渠道登录链接是 `target=_blank`。
此前只处理了 `setWindowOpenHandler`,另两条路径仍会把 Electron 窗口带到
第三方页面。现在三条路径统一外抛:
1. `window.open` / target=_blank  → setWindowOpenHandler
2. 顶层同标签跳转               → will-navigate
3. iframe 内导航                → will-frame-navigate
**loopback 例外必须保留**:channels 的服务端浏览器(noVNC)就在
`127.0.0.1:<port>` 的 iframe 里,拦掉它会让 VNC 登录失效。

实测(打包后的 GUI + Xvfb + CDP 驱动真实窗口):
  [gui] open in default browser: https://example.com/mr-windowopen-probe
  [gui] open in default browser: https://example.com/mr-navigate-probe
  [gui] open in default browser: https://example.com/mr-frame-probe
  [gui] open in default browser: https://xiaohuanxiong.com/login?code=…(真实商汤登录链接)
顶层 URL 保持在内核页面(跳转被拦下),loopback iframe 正常加载。

## ④ 顺手
- GUI 版本号 1.9.7 → 2.0.42(与内核同版本发版;package-lock 里还是 1.9.3,
  一并同步);
- `make gui-test` 跑桌面壳单测。

验证:打包后的 GUI 启动即 `/api/plugins` 列出 channels(ui.page=channels),
插件目录播种 4 个内置插件;外链四类实测均走默认浏览器。
2026-10-20 08:44:07 +08:00
662f8f02b6 fix(channels): 商汤扫码链路重建 + 双区登录 + 凭据过期口径 + 四条老化判据
① 商汤(Raccoon)扫码「链接无效」——两个真根因
- 登录页 /login/mp 已被站点删除(Next 路由 404,实测只有 404 页);
- 旧实现本地随机造 qrcode_code,而上游早已不认自造码:旧端点
  /api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending
  (服务端没有该会话)⇒ 扫码永远完不成,账号一直是空凭据。
修法:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk)——
  POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用
  /login?code=…&appname=…&source=platform(实测 200 + 微信扫码面板)
  → 轮询 /api/iam/v1/auth/mp/qrcode/login(IAM code:"OK" 与旧 code:0 都认)。
  服务端建码失败才退回旧本地码并打 warn。UI 对扫码流程改文案为
  「用微信扫码」(原本文案会让人以为点开就自动登录)。
生产实测:链接 HTTP 200、轮询回 pending(会话真实存在)。

② 双区登录(P7)
- 凭据增加 region(intl|cn,缺省=历史行为);start_login 接受 region,
  会话按区域选端点,完成登录写进凭据;
- CodeBuddy:cn=copilot.tencent.com(platform=ide)/
  intl=www.workbuddy.ai(platform=workbuddy-ai,X-Domain、
  X-Product-Code、UA 按区域;实测 auth/state 回 200 信封);
- Qoder:intl=openapi.qoder.sh+qoder.com(client_id e883ade2-…)/
  cn=openapi.qoder.com.cn+qoder.cn(client_id 不同 732aef47-…,照参考
  QODER_CN)。CN 无公开 chat 端点 ⇒ CN 只做登录/领取;
- UI:buddy/qoder 详情页增加「+国际区」按钮。

③ 凭据过期时刻的时区口径(P6 真根因)
credentialExpiresAtMs 把 ISO 过期时刻按本地时区解析,而 codearts 给的是
UTC ISO(2026-10-19T16:42:43.06Z)⇒ 本机 +08 下过期时刻虚早 8 小时 ⇒
refresh_creds 每 30 分钟都判"已过期"并对华为云真打一次 STS(token 本还有
23 小时寿命)。改为按声明时区解析(Z / ±hh:mm / 无后缀=UTC 三种形态同一
epoch),补 TestCredentialExpiresAtMsParsesISOAsUTC。

④ 四条老化/假红判据
- TestCreditsPoolReason*:account_add 触发的选号会用构造期默认余额 (0,0)
  先查一次并缓存 60s ⇒ 之后改假上游读不到("余额 100 却报 exhausted")。
  harness 改余额后清 TTL 缓存(__ttl_reset_probe,与生产同一实现)。
- TestAccountReorderActually... / TestPoolListOrderIsDeterministic:随
  「一通道一源」失效(按 channels-buddy-<acct> 过滤恒得空)。改写为——
  重排必须切换服务账号(新增 __chosen_account_probe),顺序确定性改为断言
  选号顺序(__rank_accounts_probe)由 id 稳定决定。
- channels.lua 顶层 local 余量:区域表与 5 份适配器源码常量收进 plugin.K
  (TestChannelsLuaHasLocalHeadroom 需要 ≥10 槽位);ZCode 适配器源码里的
  CLI_PREFIX 必须留在源码内(适配器沙箱没有 plugin 全局)。

⑤ 错误文案
openai_error 增补腾讯 codebuddy 三层嵌套 {error:{data:{code,msg}}}:实测
429 code=14018「额度已用尽」此前落到"不识别",客户端只看到通用文案。

验证:core 434s 全绿、lua 62s 全绿、provider 全绿;生产已部署并实测
raccoon 链接 200 / trae 非流式 200 / zcode 适配器加载。
2026-10-20 08:27:28 +08:00
2113bfc125 fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器
生产实测逐渠道排查(2026-10-19):

* qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串;
  qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test
  走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包
  {access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。

* trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧),
  transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报
  invalid character 'e'。补 aggregate_solo_sse 聚合。

* buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制
  stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。

* provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为
  tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。

* opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML);
  兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。
  opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹
  分桶),保留免费层门禁四要素。

* 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的
  隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。

* channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号
  闪现在 opencode 卡片下的渲染竞态。

* channels request_end hook:挂接丢失的 isModelGone/recordDeadModel ——
  真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进
  deadModels,不再每轮 404 仍可选中。
2026-10-19 22:32:18 +08:00
c9f34538ec fix(codearts): 识别 InferHub 未注册模型 2026-10-19 18:02:30 +08:00
12bfd53f16 fix: 收口渠道源迁移与测试页合并 2026-10-19 17:21:22 +08:00
25970a2135 refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据
用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」

## 一、删除 priority(它从未参与跨源调度)

`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。

- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority

## 二、一通道 = 一个适配器 = 一个源(channels 插件)

旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效

新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
  (内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
  跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
  时给当前账号打 termErr,下次选号跳过;成功则清标记。
  ⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发

## 三、discover 支持用已存凭据(真实缺口)

刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。

- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
  做探测,那就成了 SSRF/密钥外带);响应不回显 key

## 四、image_models 显式声明(补元数据缺失)

`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。

不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。

## 五、UI:对话与生图合并为一个导航项

两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。

## 测试

- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
  切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
  一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
  (TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
  (验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"

gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。

BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。
2026-10-19 16:26:59 +08:00
6f2d4dc6eb fix(plugins): 三个坏渠道 + 三插件图标 + 计费可见性
用户报障:「检查一下,同时修复坏渠道,还有插件不是可以自己决定注册
哪些渠道吗?你怎么不修一下,还有除了 billing 以外的两个插件都没有正确的
图标,而且另外两个插件计费也不正确」

## 一、坏渠道(都在插件里修,不动内核)

### 1. zcode —— base_url 吞掉了 endpoint 路径(404)
`base_url = https://zcode.z.ai/api/v1/zcode-plan/anthropic`
`endpoint = /api/v1/zcode-plan/anthropic/v1/messages`
内核 URL = base_url + endpoint(provider.go ChatURL)⇒ 路径拼两遍:
`.../anthropic/api/v1/zcode-plan/anthropic/v1/messages` → 404 page not found。
修:base_url 只留 host,完整路径进 endpoint。与 v2.0.30
「12 平台 base_url 侵占 endpoint」同族。

### 2. minimax —— 打到 Web 前端(404「简单指令,无限可能」)
base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions,那是 Web 前端。
照参考 minimax-product.ts:
  MINIMAX_INFER_PATH = /mavis/api/v1/llm/v1/messages(**Anthropic Messages**)
实测(2026-10-19):
  /mavis/api/v1/llm/v1/messages   → 401 invalid access token(路径对)
  裸 /chat/completions            → 404 page not found
  /mavis/api/v1/chat/completions  → 404
修:endpoint 指向真实推理路径 + adapter 改 anthropic(第二层:协议形状)。
修后从 404 变 401(凭据问题,不再是路由问题)。

### 3. codearts —— 只声明"可续期"却没声明"怎么续"
`account_refresh` 走 `refreshCredential()`,要求 `plat.refresh.url` 或
`refresh_fn` 存在。codearts 只有 accountView 里硬编码的 `refreshable=true`
⇒ 恒返回「该平台未声明续期方式」,自动续期链空转,日志反复
`模型刷新失败 HTTP 400`。而 STS security_token 只有 1 小时寿命,过期后
每个请求都是 `APIG.0602: the security token has expired`。

修法(三层,每层都有独立的坑):
a) 加 `refresh_fn` 钩子。华为云 STS 要 **DPoP 头**(jwk 在凭据里),
   声明式 REFRESH_STYLES 拿不到私钥 ⇒ 必须用钩子挂已有的
   `codeartsRequestToken`。
b) **JWK → PEM 重建**。`codeartsNewDpop()` 返回 {pem,public,private},但
   凭据只持久化了 private(JWK),pem 是登录临时文件。而 `codeartsDpopSign`
   走 openssl 命令行要 `key.pem` 字符串 ⇒ 直接传会报
   `bad argument #1 to 'write' (string expected, got nil)`。
   手工组 SEC1 `EC PRIVATE KEY` 的 DER(INTEGER 1 / OCTET STRING d /
   [0] OID prime256v1 / [1] BIT STRING 04||X||Y)再 PEM 包装。
c) 绑定放在**文件末尾的绑定循环**(与 claim_fn 同处)。在 PLATFORMS 表里
   内联会闭包捕获当时的全局 nil —— 与 trae headers_fn 那次同一坑。

## 二、插件图标(3 个坏 1 个)

billing 有 SVG 但**缺 stroke/fill=currentColor**(深色主题下不可见);
channels 声明 `icon = ""`(侧边栏空白);quota 连 icon 字段都没有。
统一成 inline SVG + `stroke="currentColor"`(照参考 src/sponsor.js:52 的
图标工厂形状)。

## 三、判据(4 条,全部做过变异验证)

- `TestChannelBaseURLDoesNotSwallowEndpoint`:base_url 里的路径段不得在
  endpoint 重复出现。**走真实加载插件**而非正则读源码 —— PLATFORMS 是
  Lua 表,有注释/条件拼装/末尾绑定循环,正则会给出假绿。
- `TestMinimaxUsesAnthropicProtocolAndRealInferPath`:路径 + 协议双断言。
- `TestCodeartsDeclaresRefreshHook`:分开报 has_refresh_fn / has_refresh_decl。
  只看 refreshable 不够 —— 那是"凭据类型可续",与"平台实现了续期"是两件事。
- `TestCodeartsRebuildsPemFromJwkAndSigns`:**必须真签名**。DER 结构错、
  长度错、base64 换行错都得到"非空但无效"的 PEM,只断言非空等于没验。
- `TestB64urlDecodeHandlesLongInput`:补一个真实测漏 —— 我的 b64urlDecode
  第一版在输出字节后没清高位(缺 `buf = buf % (2^nbits)`),43 字符的 JWK
  字段(32 字节)测出来**完全正确**,于是 PEM 判据在变异后依然全绿。
  只有 86 字符的长输入才暴露(double 精度耗尽 → string.char 抛 invalid value)。
  这条判据就是为"能跑、能返回、产物错"那类缺陷写的。
- `TestEveryPagePluginDeclaresSVGIcon` / `TestIconUsesCurrentColor`:
  按**页面插件清单**遍历而不是只覆盖 billing。billing 当年空串的判据只管
  它自己,于是 channels 空串、quota 缺字段时全绿 —— 同类缺陷换文件复发
  没有任何东西会响。这条测试还当场咬出了 billing 自己缺 currentColor。

变异验证:zcode base_url 回退 ✓ / minimax adapter 回退 ✓ / 删 refresh_fn
绑定 ✓ / 去掉 buf 掩码 ✓ / OID 多套一层 TLV ✓(5/5 全部咬住)

## 四、计费(诊断,未改价)

查清"计费不正确"的根因:`prices.models` 只配了 **2 条**
(deepseek-v4.1-flash + 它的 / 前缀形式),而实际在用 **193 个模型**
⇒ 112006 次请求里 **35672 次(31.8%)成本按 $0 计**。
其中 28203 次集中在 2026-10-05 一天(历史一次性事件);
当前每天 27~137 次,且**渠道模型全部未定价**
(Doubao-Seed-2.1-Pro / GLM-5.3-Flash / MiniMax-* / qwen3.6-plus / hy3)。

调研:参考插件从 **models.dev** 拉模型目录(含 cost.input/output)。
实测 models.dev 覆盖我们 139/193 模型(72%),但**不能取中位数** ——
同一模型不同 provider 价差可达数千倍(glm-5.2 从 0 到 1.82),
0 价多是订阅制/coding plan。按 host 匹配 provider 只覆盖 6/24 源。
⇒ 价格口径需要用户确认,本轮不动价表。

6 个新探针(仅测试/诊断用):__platform_def_probe(平台定义真相)、
__codearts_jwk_pem_probe(PEM 重建 + 真签名)、__b64_probe(解码字节)。
2026-10-19 13:43:18 +08:00
aff8354c8a fix(channels,provider): 渠道模型不可用 + 额度异常的三个根因
## 背景
用户报三问题:① channel 注册的模型无法使用;② 额度显示依旧异常;
③ 其他渠道应也存在类似问题。三条全部核实并给出结论(③ 见文档)。

## ① 网关解不开 `源名:模型名` 限定名(internal/provider/registry.go)

`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
而渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**),于是
`channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 被切成

    src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"

⇒ 源名永不匹配 ⇒ `Resolve()` 返回 nil
⇒ 客户端看到 `model "..." is not configured`。

**为什么这个写法必须支持**:本实例有 **44 个模型被多源同时提供**
(`glm-5.2` 由 6 个源提供),WebUI 的模型下拉框用 `源名:模型名` 作为
option value 是**有意设计** —— 用户必须能指定"从哪个源调用"。

修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
这是唯一可靠办法),保留旧 `-:/` 兜底以兼容非源名形态。
`EffectiveModel` 同步修(同一拆分规则,避免两处漂移)。

## ② trae 适配器三连错(internal/lua/plugins/channels.lua)

三个错误逐层叠加,每个都被上一层掩盖:

**(a) 没有 `build_headers`** ⇒ 内核兜底发 `Authorization: Bearer`,
而 trae 推理端点要 `Cloud-IDE-JWT` + 设备身份头 ⇒
401「We're sorry, but we are not able to authenticate you」。
→ 与上一轮「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点漏了)。
教训:修"某一族请求的鉴权"时,必须把该族的**所有出口**列出来逐个对齐。

**(b) header 常量的值是错的**(照参考 trae-product.ts 的 TRAE 配置逐项核对):
  · `X-App-Id` 抄成 `6eefa6c9`,真实是 `6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`
  · `app_version_code` 用了 `0.1.52`,真实是**日期式** `20260811`
⇒ 上游 `4001 bad request: binding: expr_path=app_version_code,
   cause=parameter type does not match binding data`

**(c) 流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**:
    event:output → data:{"response":"###","reasoning_content":null,...}
正文在 `response`,推理在 `reasoning_content`。
  · 套 `__prelude.openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`
  · 改成 OpenAI 外壳 `{choices:[{delta}]}` **仍然空** —— 内核
    `json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全被丢弃
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。

⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
   因此判别帧类型只能靠**内容特征**(`response` / `reasoning_content` /
   `finish_reason` / `prompt_tokens`),拿不到 event 名。

## ③ 余额恒为 0(同文件 balance.total_fn)

读的是 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
(参考 trae-credits.ts:341)+ `pack.usage.credits_amount`。
实测上游:`免费`(无该字段) / `每月登录赠送`(500) / `签到奖励`(100) ⇒ 实际 600。
修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
修后余额 600 → 实测 598.516(真实扣减)。

## ④ 夹具与生产形状不一致(internal/core/channels_trae_test.go)

夹具原先返回**扁平** `{"Token":...}`,与生产 `{"Result":{"Token":...}}` 不符
⇒ 「代码只读顶层」这个缺陷在夹具里**恰好是对的**,所有 trae 判据都绿。
**夹具形状错 = 判据必然虚假通过**。这是 ExchangeToken 缺陷藏了两轮的原因。

## 判据(新增,变异 4/4 咬住)
- `internal/provider/registry_pinned_name_test.go`(4 用例)
- `internal/core/channels_trae_chat_headers_test.go`(3 条)
- `internal/core/channels_trae_solo_sse_test.go`(2 条)
- `internal/core/channels_trae_credits_hdr_test.go`(1 条)
- `internal/core/channels_trae_exchange_shape_test.go`(2 条)

## 文档
`docs/channel-repair-plan.md`:现状快照(生产实测)+ 各渠道缺陷矩阵 +
后续计划(P0-P4)。**如实记录**:其余 4 个渠道(zcode/cline/gemini/qoder)
有同类缺陷风险,但**账号数均为 0、无法做生产验证**。

## 生产已实测
- `channels-trae-...:Doubao-Seed-2.1-Pro` → 80 帧,正文 27 + 推理 299
- `channels-trae-...:glm-5.2` / `channels-buddy-...:hy4-preview` → OK
- `credits/status` → active:true, dailyCredit:100, todayCheckedIn:true
- `credits/balances` → total:598.516 credits
2026-10-17 17:38:23 +08:00
091f790734 feat(channels): reap_orphan_sources —— 清理 v2.0.28 事故留下的存量孤儿源
## 发现路径:查"功能都正常了吗",逐源核对时发现的
生产 `config.yaml` 里有 21 个源,其中 `channels-buddy-585b335b64cf`
的账号 `585b335b64cf` **早已删除**(v2.0.23/24 的无凭据账号清理),
但它的源**仍在对外服务**:

- 独占提供 `hy4-preview-x` ⇒ 实测报 `404 Route Not Found`;
- 对 **6 个共享模型**则排在新源**前面**先被尝试(config 文件序即注册序)
  ⇒ 每次都撞 404 再回退,**每次白付一次上游往返**(实测 hy4-preview
  稳定 3.1~3.5s);
- 它的 base_url 还是 v2.0.30 之前的旧口径
  (`https://copilot.tencent.com/v2/chat/completions`,侵占了 endpoint 路径)。

**根因**:这正是 v2.0.28(`remove_source` 漏声明 ⇒ 撤源全被拒)日记里
那句"账号删了、它的源还在对外服务"的**存量残骸**。
v2.0.28 修了声明,**只防新增、不清理存量**。

**发现的一个助力**:v2.0.38 刚修好的错误文案让它暴露得更清楚 ——
客户端拿到的是 `404 Route Not Found`(APISIX 原文)而不是 `unknown error`。

## 修法
1. **立即清理**:走内核正式 API `DELETE /api/sources/<name>` 删掉该孤儿源
   (不手工改配置)。清理前备份配置,三重确认它确无对应账号。
2. **结构性修复**:新增 `reap_orphan_sources` 正式入口
   (`POST channels/reap_orphan_sources`,admin),识别
   `channels-<已知平台>-<账号id>` 且账号**不在**池中的源。

### 安全性(破坏性操作两条纪律,照 `reap_loopbacks` 先例)
- **只删该删的**:按**已知平台 id 前缀**逐个匹配。
  ⚠️ **不能用** `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的,
  会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main"
  (账号 id 含 `-` 就错,而 `buddy-main` 就含)⇒ **孤儿漏杀**。
  运维手工配的源、旧版单账号源 `channels-<平台>` 一律不碰。
- **`dry_run` 默认开启**:不带 `dry_run=false` 只报告不删。

## 判据(`channels_orphan_source_test.go`,变异 2/2)
- 造出**真实形态**:`account_add` 建账号 → hidden 探针摘池(保留源),
  模拟"源留下"这个状态(不走 `account_delete`:那条路径会把源一并撤掉,
  而我们要的恰恰是它留下的形态)。
- 断言三件事:dry_run 只报告不删、真删后孤儿消失、**手工源全程存活**。
- **判别性用例**:第二个孤儿的账号 id 含 `-`(`dead-acct-1`)。
  这是唯一能区分"按已知平台前缀匹配"与"贪婪 match"的用例 ——
  实测不含 `-` 的 id 两种实现结果相同,**咬不住**(第一版就栽在这)。
- 变异:① `dry_run` 默认改 false ⇒ 报红;② 改回贪婪 match ⇒ 报红。

## ★ 过程中踩到的三个坑(都已记进代码注释)
1. **`Plugins.Call` 的参数是平铺的,不是 `req.body`** —— `bodyOf(req)` 返回空表,
   `dry_run=false` 被静默丢成默认 true ⇒ "真删"永远没发生。
   必须同时兜住两种调用形态(HTTP 路由走 body,插件 Call 平铺)。
2. **内核 `add_source` 取 `args["source"]`(嵌套一层)**,平铺字段被拒
   ("add_source requires a source table")。
3. **`config.Model` 要求 models 是对象数组 `{id=...}`**,传字符串数组被拒。
4. **判据只查 Go 侧 `err` 会静默通过**:内核把失败放在**返回体**里,
   Go `err` 仍是 nil ⇒ "源根本没注册"看起来像"没有孤儿"。
2026-10-17 08:34:21 +08:00
6eeb5832a3 fix(provider): 上游错误文案别再丢 —— 补网关/腾讯错误体形状 + 修误导性日志
## 发现路径:读生产日志(不是测试报红)
```
unhandled upstream error body (adapter "openai" lacks transform_error):
  status=404 body={"error_msg":"404 Route Not Found"}
unhandled upstream error body (adapter "buddy" lacks transform_error):
  status=400 body={"code":11101,"msg":"Non-stream chat request is currently not supported",...}
```

### ★ 缺陷 A:错误体形状漏了「网关」与「腾讯系」两类
`__prelude.openai_error()` 只认 `error.message` / `error`(裸串) / `{code,message}`
/ `detail` / HTML `<title>` / 裸文本 六种,**没有** `error_msg`(华为/APISIX 网关)
与 `msg`(腾讯系用 msg 而非 message)⇒ 返回 nil
⇒ 客户端只看到 `api error 404: unknown error`,**而 body 里明明有可用文案**。

修法照参考(llm-adapter.ts:524 `errorDetail()` 显式认 `error_code`/`error_msg`;
buddy-adapter.ts:576 认 `msg` + `displayMsg.{zh,en}`):
- 新增 `{error_msg}` / `{msg}` / `{error_code,+error_msg}` 三种形状;
- 新增 `displayMsg.{zh,en}` 本地化文案,**排在最前**(参考注释明说"本地化文案
  最可读";其早期实现只读 `error.*`/`message` ⇒ 全部落空 ⇒ 把 291 字符原始
  报文糊进错误提示,而服务端早已备好中文说明)。

覆盖 7 个用共享前导的适配器:buddy/deepseek/github/groq/kimicode/openai/sensenova。

### ★ 缺陷 B:内核那句日志会把人带偏
措辞是 `adapter %q lacks transform_error`,但 **openai.lua:139 与 buddy.lua:166
都明明定义了该钩子** —— 真实原因只是它**返回了 nil**(认不出那个形状)。
我照着这句话去适配器里找"有没有这个函数",查了几轮才发现函数在、是返回值空。
修法:内核直接查事实(`p.vm.HasFn(adapter, "transform_error")`),
两种原因**分开措辞**,不再猜。

## 判据
`internal/lua/adapter_prelude_error_shapes_test.go`:
- 12 个形状子用例(4 个新增 + 6 个回归 + 2 个 displayMsg),
  **装载仓库里真实的 openai.lua**(含共享前导),不是自造桩 ——
  第一版用空 VM 跑,12 个全 FAIL(`adapter openai not loaded`),
  那等于什么也没验。
- 配对判据 `...StillReportsUnknownShapes`:真正认不出的形状仍须 `ok=false`,
  防止"为了让上面通过就把『抽不出』和『没这个钩子』混为一谈"。
- 变异:删掉新增的 error_msg/msg 分支 ⇒ APISIX 用例报红。

## 备注
`anthropic/gemini/mistral/ollama/opencodezen/qwen38/trae` 有自己写的
`transform_error`,不走共享前导,**未获得**本次新增形状(各自协议的错误体
本就不同)。此处只修共享前导的 7 个。
2026-10-17 00:37:32 +08:00
a189c4f61c fix(channels): zcode 适配器真正继承 anthropic 基座(此前 9 个函数只有 2 个)
## 缺陷(潜伏,从未被触发)
`channels-zcode` 适配器的手法是把 `anthropic.lua` 源码读进来执行、只覆盖
`transform_request`(身份块注入),其余协议转换全部复用基座。

但那句读取写在**适配器内部**。实测:
- 适配器运行在**另一个沙箱 VM** —— 那里 `__llmsproxy_plugin_dir` 与
  `LLMSPROXY_PLUGIN_DIR` **都不存在**、**也没有文件系统访问**;
- ⇒ `loadAnthropic()` 必然返回 nil,**且静默降级**:适配器照常装载,
  但只有它自己定义的 **2** 个函数,缺
  `transform_response` / `transform_stream_chunk` / `serialize_*` /
  `deserialize_request` 共 **7** 个。

**后果**:内核 `Transform()` 对缺失函数**响亮报错**(不是静默透传)⇒
非流式在响应阶段 502,流式**逐帧**报错 ⇒ 该渠道**完全不可用**。

**为什么一直没暴露**:zcode 从来没有凭据(0 账号)⇒ 源未注册
⇒ 该路径从未被调用过。拿到账号的那一刻才会炸,且报错指向内核而非配置。

## 修法
基座源码改由**插件主体**读好并**内联**进适配器源码(插件 VM 有 io,
实测可读)。与 v2.0.35 buddy 适配器同一手法。

两处必须注意的坑:
1. **不能拼接后 gsub 替换**:gsub 的替换串里 `%` 有特殊含义(`%1` 是捕获
   引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
   改用 `find(ph, 1, true)` + `sub` 的纯字符串替换。
2. **占位符 token 不能在注释里出现字面量**(第一版就栽在这:gsub 先命中了
   解释它的那行注释,替换掉注释、代码里仍留着未定义全局)。
3. 顺带补上**插件主体缺失的 `plugin.adaptersDir()`** —— 原先这个函数只
   存在于 zcode 适配器源码**内部**,插件主体从来没有过。

## 判据
- `internal/core`(**真实路径**,会咬变异):
  - `TestZcodeAdapterInjectsOfficialIdentityBlocks`
  - `TestZcodeAdapterKeepsAnthropicProtocolConversion`(工具调用 / 图像块存活)
  - `TestZcodeAdapterFailsLoudlyWithoutAnthropicBase` —— 语义已随设计更新:
    基座不可得现在应在**安装期**失败(新增 `adapters_dir` 测试缝隙把
    "基座缺失"从运行期逐帧炸变成安装期一条明确错误)
- `internal/lua/adapter_zcode_base_test.go`:验证源码模板 + 合并逻辑,
  **并如实标注边界** —— 它在测试里自己做替换,所以**不能**证明安装处内联了
  非空基座(实测:把 `asrc` 换成空串它不报红;那份由上面的 core 判据覆盖)。

**变异验证**:把安装处的 `asrc` 换成空基座 ⇒ core 三条判据全部报红;
用重构前的适配器源码 ⇒ `TestZcodeAdapterInheritsAnthropicBase` 报红(缺 7 个)。
2026-10-17 00:01:00 +08:00
58073a6cbe refactor(channels): 纯常量收进 plugin.K,腾出顶层 local 余量(此前余量为 0)
## 问题(实测)
`channels.lua` 的主 chunk **顶到 Lua 上限 200 个 local** ——
往文件顶部加**哪怕 1 个**顶层 local 就报:
  `main function has more than 200 local variables`
实测(LuaJIT 与 luac 5.4 一致,**响亮失败**,不是静默降级)。

后果不是"某个功能出错",而是**整个渠道插件编译失败 ⇒ 全渠道不可用**。
换句话说:这个文件此前**一个顶层 local 都加不了**,任何后续改动都会立刻炸。

## 修法
把 20 个**纯常量**(字符串/数字/自闭合字面量)从裸 `local` 收进 `plugin.K` 表 ——
**挂表字段不占 local 槽位**。只搬常量,**函数一律保留 local**:
函数之间存在真实的调用顺序依赖(前向引用),搬迁风险与收益不成比例。

搬迁是纯机械的,且脚本带**长字符串保护**:替换时逐行跳过
`[===[ ... ]===]` 区间,绝不改动适配器源码文本
(本文件里那 7 段长字符串是适配器代码,误改会直接弄坏适配器)。

### 实测结果
| 指标 | 前 | 后 |
|---|---|---|
| 加 1 个顶层 local | ✗ 编译失败 | ✓ 通过 |
| 加 10 / 20 个 | ✗ | ✓ 通过 |
| 字节数 | 589195 | 590042 |

## 判据(2 条 + 变异 2/2)
- `TestChannelsLuaHasLocalHeadroom`:注入 1 与 10 个顶层 local 后**仍能编译**。
  为什么用 **luajit** 而不是 `luac`:内核链接 `libluajit-5.1.so.2`,
  而 luac(Lua 5.4)对同一文件给出**不同结论**,用错工具会得到假绿。
  为什么不"数一遍 local":主 chunk 的 local 数受作用域嵌套影响,
  静态计数不可靠(同一文件被不同脚本数出 228/232/248 三个数);
  而"能不能再加"是**决定性**的,也正是我们需要的性质。
- `TestChannelsConstantsLiveInPluginK`(配对):抽样断言这些常量确实在
  `plugin.K` 内、且**没有**变回裸 local —— 否则余量会再次归零。
- 变异:用重构前的文件 → 报红;把常量改回裸 local → 报红。

## ★ 顺带更正两处我先前的错误说法(本轮实测推翻)
1. **不是静默失败**。我先前说"超出的 local 静默退化为全局" —— 错。
   LuaJIT 与 luac 都在第 201 个报**编译错误**。危险度远低于我所说。
2. **`adaptersDir` 为 nil 的真实原因不是"local 被顶掉"** ——
   它定义在 **`ZCODE_ADAPTER_SRC` 长字符串内部**(适配器源码文本),
   在**适配器沙箱**里执行,而那里没有 `__llmsproxy_plugin_dir`、没有文件系统。
   所以 zcode 的 `loadAnthropic` 必然返回 nil、BASE=nil
   (它自写了全部转换所以功能没问题,但"复用 anthropic、协议不漂移"的
   设计意图从未生效)。
   ⇒ **教训**:读 10000+ 行的 Lua 文件时,先扫出所有长字符串区间;
   落在里面的"函数定义"只是文本,不属于插件作用域。
2026-10-16 21:49:12 +08:00
a5923b3797 fix(buddy): 新增内置 buddy 适配器(glm-5.1 的 reasoning_effort + 流式 Accept)
## 真实缺陷 ①:glm-5.1 恒 400(用户报"这个模型不可用")
上游回 `400 {"code":11150,"msg":"the reasoning effort value is not supported
by the current model"}`。探针逐字对照实验(body 完全由探针给定):
  glm-5.1 裸(无该字段)             → 400 11150   ✗
  glm-5.1 + reasoning_effort:medium  → 200         ✓
  glm-5.1 空串                       → 400 11150   ✗
**任何非空档位都被接受**(low/medium/high/xhigh/max/minimal 逐个 200);
而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)⇒ 这是 glm-5.1 **独有**要求。
参考 product.ts:237 声明它只有 `['medium']` 一档。

## 真实缺陷 ②:流式请求缺 Accept: text/event-stream → 空回复
不发该头时上游**接受请求(200)但只回一帧空内容**(completion_tokens=0、
finish_reason 直接 stop),既不报错也不产出正文 —— 表现是"模型不回答",
极易误判成模型坏了或额度不足。同 body 加该头 → 39~43 帧正常 SSE。
参考 buddy-adapter.ts 的 send() 固定发它。

## 实现:内置 `buddy.lua`(不是插件自装)
**为什么不用插件自装适配器**(channels-zcode/qoder/cline 那条路):
`channels.lua` 的**主 chunk 已 248 个 local**,Lua/LuaJIT 上限是 200 ——
超出的 local 会**静默退化为全局**(`luac -p` 通过,运行时才 `attempt to call
global 'x' (a nil value)`)。实测证据:`adaptersDir` 是第 208 个 ⇒ 调用点拿到 nil
⇒ `loadAnthropic` 静默返回 nil ⇒ **zcode 适配器从来没有基座**
("复用 anthropic"的意图从未生效,只是它自写了全部转换所以能用)。
我第一版就是这么写的,结果适配器装不上(`adapter channels-buddy not loaded`),
且 `hy3`/`glm-5.3`/`kimi-k3-1` 全被打成空回复 —— 只有 `glm-5.2` 因多源兜底
"看起来正常",症状再次指向错误方向。

⇒ 改走内置适配器(与 trae/sensenova/opencodezen 同性质,`deploy.sh` 自动同步),
`channels.lua` 只改**一行** `adapter = "buddy"`,不再触碰那个已贴上限的文件。

## ★ 排查中发现的沙箱事实(值得记)
**适配器运行在另一个沙箱 VM**:那里 `__llmsproxy_plugin_dir` 与
`LLMSPROXY_PLUGIN_DIR` **都不存在**(实测均为 nil),**也没有文件系统访问**。
故任何"在适配器里读兄弟适配器源码"的写法都拿不到基座且静默降级。
判据 `TestBuddyAdapterDefinesAllTransforms` 咬住这条。

## 顺带修:hy4-preview-x 从目录剔除
实测它对上游**恒回 11102**(`model service info not found`,加/不加
reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。它被服务端 agents
引用,于是 `agentRef` 例外把它放回目录 ⇒ 用户选中必 400 而错误指向模型。
参考用**白名单重建**达到同样效果(其兜底表里没有它)。本仓加等价的精确黑名单
(只放实测确认的,不做猜测),并在两处兜底目录里一并剔除。

## 验证
buddy **15/15 模型全部可用**(含 glm-5.1),逐一实测无失败;
`hy4-preview-x` 已从源目录消失(16 → 15)。

## 判据(3 条 + 变异 2/2)
- `TestBuddyAdapterEffortAndHeaders`:glm-5.1 补 medium / hy3 不补 /
  调用方给的档位被尊重 / Accept 必须是 SSE + 身份头齐备
- `TestBuddyAdapterKeepsOpenAIWire`:协议转换与 openai 一致(只是补两个字段)
- `TestBuddyAdapterDefinesAllTransforms`:**四个转换函数都存在** ——
  咬"静默降级"而非"某模型能回话"(后者会被多源兜底掩盖,本会话已栽两次)
- 变异:删 stream_chunk / 不补 effort → 均报红

## 一起提交的其它独立缺陷(均在本次调查中发现)
- buddy 目录过滤(四类判据)与 hy4-preview-x 剔除
2026-10-16 20:47:14 +08:00
91f6d1f32f fix(channels): 日志截断按字符(中文被切出 U+FFFD)+ 面板排版
## 真实缺陷(v2.0.33 上线后在截图里看到)
日志尾显示成:`能力增强不�`
根因:`tostring(cerr):sub(1, 120)` —— **Lua 的 sub 按字节切**,而中文占 3 字节
⇒ 切在字符中间产生**替换字符 U+FFFD**。一句本来完整的诊断("…能力增强不可用,
已退回兜底表")看起来像**数据损坏**,实际只是截断方式错。

生产实测复现:该条 120 字节截断后,末尾正好落在"不"字的第 1 字节上,
python 侧解码得到 `…能力增强不\ufffd`。**修法**:`plugin.utf8Clip(s, maxBytes)`
先按字节切、再回退到最后一个完整 UTF-8 序列边界(最多退 2 字节)。
三处中文文案截断全部改用它。

## 判据(1 条静态 + 1 条行为 + 变异 1/1)
- `TestLogTruncationIsUTF8Safe`(静态):助手存在 / 三处中文截断不再用裸 sub /
  未超长时原样返回 / 回退逻辑处理孤立首字节
- `TestUtf8ClipBehaviour`(**行为**,经 `plugin.__utf8_clip_probe` 走真实函数):
  对 6 个长度断言 `utf8.ValidString(out)` 且不超字节上限。静态断言看不出
  "结果是否真的合法 UTF-8",所以必须有这一条 —— 实测 n=120 从
  `…能力增强不�`(非法)变为 `…能力增强不`(合法)
- 变异:把 utf8Clip 换回裸 sub → 报红

## 排版抛光(截图里可见的三处)
- 头部控件原用 `<h2>` + flex,checkbox 被挤到换行且文字竖排 ⇒ 改 `<b>` +
  独立 flex 行 + `white-space:nowrap`
- 长诊断在窄卡片里被**截断**(而截断的尾巴正是最该看的部分)⇒ `word-break`
- 列表加 `line-height`,可读性

## 兼容性
历史 state 里的 41 条老条目没有 `level` 字段 —— 前端 `e.level || "info"`
已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。
2026-10-16 17:44:04 +08:00
c1048dcd4e fix(channels): 运行日志加级别/归属/折叠,并从渠道详情移出(C+A)
## 问题(生产实测,非猜测)
用户问「为什么 WebUI 默认显示插件全部日志」。用 CDP 打开真实 UI + 读
`/channels/status` 实测,发现是**三个问题叠加**,而表层症状是"看到的全是噪音":

### ① 归属错误:全局日志渲染在「单个渠道详情」里
`plugin.state.log` 是**单一全局数组**(`logEvent` 写入的条目只有 `{at, msg}`,
**连 platform 字段都没有**),但这个 `<ul id="ch-log">` 的 DOM 位于
`#ch-detail`(点开某渠道才显示)⇒ 用户点开 buddy,看到的却是
trae/minimax/qoder 的日志混在一起,**无从区分**。

### ② 50 条里 48 条(96%)是周期任务噪音
实测分类:`已同步源…` 11×、`模型刷新失败…` 9×、`模型目录已刷新` 9×、
`能力元数据未刷新` 9×、`自动领取…` 9× —— **唯一模式只有 8 种**。
原因:`refresh_models`(2h) + `auto_claim`(2h) + `refresh_creds`(0.5h) 三个
`first_due_now` 任务每次跑写 3~5 条 ⇒ **约 2 小时就把 50 条环形缓冲冲满**,
于是真正的用户动作(如「账号删除」)被挤到看不见。

### ③ 无级别、无去重、无折叠 → 噪音与错误同权重并排

## 修法(用户选 C+A)
**C —— 修正归属**:把「运行日志」从 `#ch-detail` 移出,成为**常驻的独立卡片**
(`#ch-log-card`)。判据断言它与渠道详情是**兄弟节点**(div depth=0),
不是父子 —— 否则"点 buddy 看到 trae"会以另一种形式回来。

**A —— 加级别与过滤**:
- `logEvent(msg, level, platform)`:level ∈ info|task|warn|error。
  定时任务的**常规**结果记 `task`(前端默认折叠),只有异常升为 warn/error。
- **紧邻同文折叠**:与 `log[1]` 完全同文时不新增条目,只 `n=n+1` 并刷新时间。
  ⚠️ 只对**紧邻**条目折叠 —— 否则一条每小时出现的消息会不断"跳"到最前,
  时间线失去意义。前端显示成 `×N`,既保留"重复了多少次"又不刷屏。
- 三个视图开关:级别门槛(全部/警告及以上/仅错误)、显示例行任务、
  按当前选中渠道过滤(不带 platform 的跨平台汇总只在「全部渠道」出现)。
- `refresh_models` 的失败改为**逐平台各记一条**(原先 N 个挤成一条汇总,
  既不属于 trae 也不属于 minimax ⇒ 点进任何单渠道都看不到自己的问题)。

## 判据(3 条 + 变异 2/2)
- `TestChannelsLogEventCarriesLevelAndPlatform`:签名三参 / 条目带两字段 /
  紧邻折叠 / **折叠对象必须是 log[1]**
- `TestChannelsPeriodicTaskLogsAreDowngraded`:逐条咬住**具体调用点**被降级
  (而不是"存在 task 这个词"——那样一处就为真,测不出漏降级的噪音源)
- `TestChannelsLogPanelHasFilters`:日志卡片与渠道详情是**兄弟**(div depth=0)
- 变异:去掉折叠 / 把卡片放回详情内 → 均报红

## 顺带修好的环境事故(重要)
排查中全量测试突然大批失败(`loopback 未就绪(未取得端口)`)。我一度以为是
自己的改动,**`git stash` 后在干净版本上复现 → 预先存在**,继续追到根因:

**`/tmp/bisect.py`(另一个 agent 的临时脚本)遮蔽了 Python 标准库的 `bisect`**
—— 而 `tempfile` → `random` → `bisect`,于是**任何 `import tempfile` 都炸**,
回环接收器(python3)起不来。`sys.path` 含 `/tmp` 就是这个后果。
已移走(`/tmp/_shadowed-stdlib-conflicts/`,不删除以免打断那个 agent),
Python 与全部测试立刻恢复。

⇒ **教训**:临时脚本**绝不能**在 `/tmp` 下用 stdlib 模块名命名;
它会让全机 Python 静默失效,而症状("回环未就绪")与原因毫无关联。
2026-10-16 17:19:46 +08:00
2b8fcaf434 fix(opencodezen): 必须回传 reasoning_content(thinking 模式硬要求)
## 真实缺陷(我自己的会话被它打断过)
生产实测(2026-10-16):
  provider localzen: api error 400: Upstream request failed: [invalid_request_error]
  The `reasoning_content` in the thinking mode must be passed back to the API.
审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。

根因:`transform_request` 里写着 `msg.reasoning_content = nil`,注释理由是
"Zen 免费池不接受回传(Go 侧相反)"。**该假设方向是反的** —— 上游要的就是它。

## ★ 为什么这个缺陷极难归因(值得记)
失败是**交替**出现的:只有当上一轮**真的产出了 reasoning**(thinking 模型 +
reasoning_effort 开启 + 长上下文)时,回放那段历史才会被拒;普通回合照常 200。
于是"同一个模型同一会话,成功与失败交替"——看起来像**上游抖动或限流**,
而客户端看到的 502 与"上一轮想过"这件事**毫无关联**。

我一开始也走了弯路:用简短的 curl 复现不出来(8/8 成功),因为那些请求的
历史里没有"模型自己产出的 reasoning"。**必须让上一轮真的产生思考**才复现。

参考实现的方向一致:`openai-compat.ts` 的 `serializeMessages`(opencode 适配器
直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
    `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
即「有就回传」,无额外条件。

## 修法
保留 `reasoning_content`(只把**空串**丢掉 —— 多余字段部分上游会挑)。
不改成"仅当 thinking 开启才回传":我们**无法**从请求可靠判定上游是否处于
thinking 模式(真正知道的是上游)。而"有就回传"是零风险方向 ——
上游不需要时忽略即可(实测传与不传同为 200),需要时缺了就是硬 400。

## ★ 顺带发现的第二处问题
提交 `6113924` 的说明声称
"Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split" ——
**该测试在全仓不存在**(`git log -S` 全历史搜索为空)。也就是说这条行为
**从未被任何判据固定**,所以它反了也没人知道。本轮补上真正的判据。

## 判据(2 条 + 变异 1/1)
- `TestOpenCodeZenKeepsAssistantReasoningContent`:assistant 回合带 reasoning 时
  出站体必须有它。**咬住"被剥掉"这个具体行为**,而不是"请求成功"——
  后者在非 thinking 回合下永远为真,测不出本缺陷。
- `TestOpenCodeZenDropsEmptyReasoningContent`:空串必须丢掉(配对判据,
  防"改成无条件保留"这类过度修正)。
变异:恢复 `msg.reasoning_content = nil` → 报红。

## 说明
`opencodezen.lua` 在仓库内,而 `deploy.sh` 的 `sync_adapters` 只覆盖
**与仓库同名**的适配器文件 ⇒ 本修复会自动生效(生产另有 opencodego/opencode/
agentrouter 三个仅存在于线上的文件,不受影响)。
2026-10-16 14:52:13 +08:00
47fe759025 fix(channels): buddy 目录过滤不可调用模型(照参考 isChatModel 四类判据)
## 真实缺陷
`/v3/config` 的 `data.models` 混着**不是对话模型**的条目,我们原样全收 ⇒
生产源里 24 个模型,其中 `nes-gf` / `codewise-completions` / `codewise-rewrite` /
`codewise-jump` / `codewise-default-model-v2` / `hunyuan-image-alpha` / `auto`
全部调不通(`glm-5.1` 也 404)。用户选中报错,而错误文案指向**模型**
⇒ 极易被误判成"这个模型坏了"。

参考实现(buddy.ts `isChatModel` / `isAutoSelectAlias`)逐条给出四类判据,
且都有实测依据;本轮逐条移植:
  ① 自动选择别名 `auto` / `default`
     ⚠️ **只按这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤
     `default-model` / `fast-model`(国际版抽象别名,但被 craft agent 引用,
     是官方推荐入口)。参考注释专门警告过这点。
  ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,或 `supportsExtra`
     (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
  ③ `maxOutputTokens > 0 且 ≤ 256`(补全用途;对话模型普遍 ≥24000)
  ④ 带 `text-to-image` 标签(如 hunyuan-image-alpha)

⇒ 故 `models` 先建成 id→meta 映射(③④ 需要元数据),再逐条判定。
同时收 `data.agents[].models` 里**被 agent 引用**的清单并合并 —— 参考据此给
`agentReferenced` 打标(`hy4-preview-f` 只由本端点下发且被 agent 引用,
白名单式重建会把它丢掉)。

## 判据(1 条 + 变异 1/1)
`TestBuddyCatalogFilterDropsNonChatModels`:断言四类规则**都在**源码里
("有人精简掉一条"就会把不可调用模型放回选择器),并**反向**断言
`default` 没被写成前缀匹配(那会误伤 `default-model`)。
变异:删掉 `nes-` 规则 → 报红。

## 说明
这是上一提交(v2.0.30)同一轮的第二个发现:v2.0.30 修掉 URL 拼接后,
buddy 通道**才真正可达**,于是"可达之后目录里哪些真能调"这件事第一次暴露出来。
两个缺陷是串联的 —— 不先修 URL,这个目录问题无法被观察到。
2026-10-16 14:06:51 +08:00
d795266572 fix(channels): 8 个平台 base_url 侵占 endpoint 路径 ⇒ 上游 404 Route Not Found
## 真实缺陷(用户可见)
内核的上游 URL 是 `base_url + endpoint`(provider.go ChatURL),而适配器默认
`endpoint = "/chat/completions"`。多个渠道平台把**完整路径**写进了 `base_url`
⇒ 实际请求变成
  https://copilot.tencent.com/v2/chat/completions/chat/completions
⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,**不是**模型错误)。

受影响 8 个平台:buddy / cline / lobsterai / opencode / qoder / loomy / raccoon
(zcode 当时已用 `endpoint` 覆盖,形态正确)。
另有 loomy 的**第二处**错误:host 用了 `loomy.xunfei.cn`(Web 前端/OAuth 回调域),
实测该 host 的 /chat/completions 返回 **HTML 网页**;正确推理域是参考的
`loomyad.xunfei.cn/api/v1`(返回正常 JSON)。

## ★ 为什么这个缺陷活了很久(最重要的教训)
**模型名被多个源服务**。`deepseek-v4.1-flash` 同时由 4 个源提供
(alittokenplan/vela/oin/channels-buddy)⇒ buddy 源失败后网关**继续尝试下一个源**
并成功 ⇒ 用户看到"能回话" ⇒ "buddy 通道是好的"这个结论**完全没有证据**。
本轮我一度正是这么误判的。而 `hy3` **只由 buddy 一个源**提供 ⇒ 一调就暴露真因。

⇒ **判据必须断言源的结构不变量(URL 形态),不能靠"某个模型能调通"** ——
后者会被多源冗余掩盖。已按此写判据。

## 排查手法(可复用)
怀疑"某模型不可用"时,先列**该模型由几个源服务**:
  1 个 ⇒ 该模型是那条源的健康探针(用它测)
  >1 个 ⇒ 它调通**不能**证明任何单一源可用(换独有模型再测)
另:非流式请求会被 buddy 直接 400 拒绝
(`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`),
我第一次用 stream=false 时四个模型全 400,**看不出**模型差异,差点把结论下成
"头组合无区别"就收工。

## 修法
`base_url` 只留 host(或协议公共前缀),路径移入该平台的 `endpoint`。
逐平台按参考的 `apiBase` + `chatPath` 取值核对,并**实测**修正后的 URL:
buddy/cline/lobsterai/qoder/raccoon → 401(缺凭据,正确)
loomy → 200 `{"code":"100002","desc":"缺少 token"}`(正确)
opencode → 500(路由存在;`https://opencode.ai/v1/...` 是 404+HTML,已证伪)
测试注入平台也一并对齐(免被当范例复制)。

## 判据(2 条 + 变异 2/2)
- `TestChannelSourceBaseURLHasNoChatPath`:静态扫描全部 `base_url`,不得含
  `/chat/completions` `/v1/messages` `/generateContent` `/api/chat`。
- `TestChannelBaseURLJudgeBitesOnBadInput`:用**故意坏数据**验证判据的判别力
  (防判据自己写歪成恒真)。
变异:把 buddy 恢复成旧形态 → 报红。

## 顺带修正
`TestUpstreamGapsAreDocumented` 原先钉死 qoder 的**整串 URL 字面量** ⇒
这次纯形态重构(语义未变)把它弄红了 —— **判据在咬自己**。改为断言
`base_url` 与 `endpoint` **两半**,语义不变但对形态重构免疫。
2026-10-16 13:51:36 +08:00
a82ae2b60e fix(channels): 撤源失败的诊断必须带真正原因(不再只说"无返回")
## 我上一版(v2.0.28)自己引入的缺陷
内核桥的签名是 `call(name, args) -> (result, nil) | (nil, err)` ——
**失败时第一个返回值是 nil,原因在第二个返回值里**。
我写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒为 nil ⇒ 日志只说
「撤源(重建)失败 channels-buddy-buddy-main: 无返回」。

**一句不可操作的诊断比没有更糟**:它把"权限没授予 / 超时 / 拒删最后一个源"
三种完全不同的原因(对应三种完全不同的处置)压成同一句空话。生产实测已复现
这条日志(部署时全量重同步,权限尚未授予)。

## 修法
两个撤源点都改成接**两个返回值**,日志带上内核给出的原文原因:
  `kernel action "remove_source" is not granted to plugin "channels";
   enable it in the plugin's kernel permissions`
并把"要授予哪个动作、声明在 plugin.kernel"一并写进提示。

## 判据收紧(旧判据咬不住这个变异)
原来只断言日志含"撤源失败"和"remove_source" —— **只接第一个返回值的版本照样通过**。
新增断言:日志必须含内核给的**真实原因**(`not granted`)。
这是本会话第 9 类判据教训:**判据只看"失败了",不看"为什么失败"**,
于是它无法区分"诊断可用"与"诊断是空话"。
2026-10-16 10:26:55 +08:00
2d2c27c430 fix(channels): remove_source 漏声明(删了账号源还在对外服务)
## 真实缺陷(用户可见)
`plugin.kernel` 只声明了 5 个内核动作,而插件在 **5 处**调用 `remove_source`
—— 声明与使用**漂移**。生产按 declared 授予 ⇒ **撤源全部被拒**。

更坏的是它**静默**:`Core.AddSource` 是**同名 upsert**,所以 `syncAccountSource`
里"先 remove 再 add"的 remove 失败**看不出来**(add 会覆盖同名源)——
只有**纯删除路径**才暴露:
  • `dropAccountSource`(账号停用/删除)→ **账号删了,它的源还在对外服务**
  • `account_delete` 收尾清旧单账号源
  • 两处 `channels-<plat>` 旧源清理

我实测确认:只授 add_source 时删账号,其源**仍在**内核源列表里。

## 修法
① 声明补上 `remove_source`(并加注释说明"加动作时同时改这里")。
② `dropAccountSource` / `syncAccountSource` 的撤源**不再忽略返回值**,
   失败**记进事件日志**(面板可见,附可操作提示)——
   "一个被丢弃的返回值"不该是权限缺失的唯一痕迹。
③ 判据 `TestChannelsDeclaresEveryKernelActionItCalls`:**静态扫描**源码里所有
   `__llmsproxy_kernel.call("X")`,要求每个 X 都在 `plugin.kernel` 里声明。
   将来任何人加了内核调用却忘了声明,会在这里报红,而不是等线上静默失效。

## 判据(3 条 + 变异 3/3)
- 授予 remove_source 时:删账号**确实撤掉源**
- 未授予时:**必须可见地失败**(事件日志含"撤源失败")
- 声明必须覆盖全部实际调用
变异:忽略返回值 → 报红;漏声明 remove_source → 报红。

## 部署后需补的一次操作
生产权限集当前只有 add_source ⇒ 需授予 remove_source(否则既有账号的
"停用/删除"仍撤不掉源)。已在部署后手工授予,并复核 declared == granted。
2026-10-16 10:04:13 +08:00
0f83fc048a fix(channels): 账号顺序的确定性 + reorder 真正改内核源顺序
## ① 真实缺陷:同秒创建的账号,顺序**不确定**
`poolList` 的排序键是 `priority` 降序 + `createdAt` **升序**,而 `createdAt` 是
**秒级**(`os.time()`)⇒ 同一秒建的两个账号比较**相等** ⇒ 相等时 `table.sort`
的结果取决于 `pairs()` 遍历顺序 —— **不确定**。
实测:按 a,b,c 顺序建三个账号,`poolList` 回的是 `[a,c,b]`。

后果不只是"顺序看着乱":**源注册顺序 = poolList 顺序 = 内核尝试顺序**
⇒ 同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下影响先用谁的积分),
且可能每次进程重启都变。修法:加 **id 作稳定兜底键**(唯一且不变)。

## ② reorder 只改字段、不影响路由
`account_reorder` 只设 `acct.priority`,**不重注册源**。而一个源一旦注册,
顺序就固定了 ⇒ 重排对实际路由**毫无影响**("优先账号"并不会先被试)。
修法:重排后按新顺序**整体重放一遍** `syncAccountSource`
(remove+add 会把源移到最后,故必须整体重放,只补被移动的那几个相对顺序仍不对)。

## 判据(2 条 + 变异 2/2)
internal/core/channels_reorder_effect_test.go
- reorder 必须真的改变**内核 list_sources 的顺序**(判据看内核里的真实顺序,
  不是 pool 里的 priority 字段)
- 同优先级 + 同秒创建时,顺序必须由**稳定键**(id)决定(字典序)

★ 两个判据第一版**都不咬变异**,原因各异,值得记下:
1. reorder 判据:初始顺序恰好**已是**目标顺序 ⇒ 无法区分"重排生效"与"本来就对"。
   改法:显式断言初始顺序、并让目标顺序与之**相反**。
2. 确定性判据:我直接断言 `account_add` 之后的顺序,而 `account_add` 是
   **直接同步自己那一个账号**(源顺序 = add 顺序),**走不到排序逻辑** ⇒
   测的其实是 add 顺序。另外**多轮 in-process 反复构造也测不出** ——
   `pairs()` 顺序在同一次进程里是稳定的,只有跨进程/跨分配才变。
   改法:走**批量重注册**路径(`__resync_sources_probe`)+ 显式把 priority 拉平
   构造"排序键完全相同"的场景,再断言字典序。

## 顺带修的判据自身问题
- `sourceOrder` 原来顺带调了 `__sync_source_probe`(内部 remove+add)⇒
  **"测之前先动了被测对象"**,顺序忽而两个忽而一个。改为**只读**。
- 探针取内核返回时按 `{sources:[...]}` 取,而 `actionListSources` 返回的是
  **裸数组** ⇒ 恒得空列表。
- 夹具漏授 `add_source` 内核权限 ⇒ 源一个都不出现(内核会明确拒绝,只是我
  没看返回值)。子测试才能拿到独立的 env 与目录(同函数内 `t.Setenv` 只生效一次)。
2026-10-16 09:36:02 +08:00
1f29be43db feat(channels): credits_pool_reason —— 区分「真的用尽」与「查不到」
## 为什么需要(参考同款修复,用户报障 2026-09-29)
把两者混成一句"额度都已用尽"会让**一次网络抖动伪装成钱花完了** —— 用户于是
去解锁永久积分、或白等到明天,而号里其实有钱。两者的**正确动作完全相反**:
  exhausted → 等额度重置
  unknown   → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
  locked    → 可解锁永久积分(同样不是"用尽")

我们此前**没有**这个出口:`credits_balances` 只报逐账号错误,调用方拿不到
"为什么整池不可用"的结论,只能自己猜。

## 实现
新增 `channels/credits/pool_reason`,返回 `reason` ∈
`no_accounts | unknown | usable | locked | exhausted` + 可读 detail。
判定优先级(顺序即语义):
  no_accounts(无启用账号)
  → unknown(平台未声明余额端点 —— **无法判定 ≠ 用尽**)
  → usable(有可用账号)
  → unknown(**有查询失败** —— 不能当成用尽)
  → locked(锁定且只剩永久积分)
  → exhausted(都查到了且确为 0)

## 判据(6 条 + 变异 2/2)
internal/core/channels_pool_reason_test.go
- 余额确为 0 → exhausted
- 查询失败 → unknown(且说明必须点明"查询失败")
- 无启用账号 → no_accounts
- 有钱 → usable
- 无余额端点 → unknown(不编造用尽)
- 锁定且只剩永久积分 → locked,且**解锁后同一份余额变 usable**
  (证明 locked ≠ exhausted)
变异:查询失败也报 exhausted → 报红;locked 混同 exhausted → 报红。

## 踩到的坑(都是我的判据/夹具问题,非实现)
① 夹具用扁平 `{"total":100}`,而 buddy 的余额是**深层嵌套**
   `data.Response.Data.Accounts[]`(每包含 Status/CycleCapacityRemain/
   DeductionEndTime)—— 夹具形状不对导致全部走 unknown。
② `addAccount` 写死 platform="buddy",于是"无余额端点"那条用例在 minimax 上
   其实没有账号,命中优先级更高的 no_accounts(**正确**行为)却被判成错。
   加 `addAccountOn(platform, id)` 修正。
③ Lua 里嵌套引号:`"(...不是"额度用尽")"` 语法错 ⇒ 改用中文引号「」。
2026-10-16 09:00:47 +08:00
8109acf7cd feat(trae): batch 模型目录(含参考记录的两个真实缺陷修复 + 元数据)
我们此前 trae 的模型列表是**纯硬编码**(32 项名字),既没有真实元数据,也没打
batch 端点。参考的 parseTraeBatchModelList 记录了**两个已修的缺陷**,移植时
必须一起带上,否则等于把 bug 再引进一遍:

## ① 通道白名单是「准入」而不只是「排序」(issue IKJOZ7)
22 个 function 里有一半在本插件推理端点下**不可调用**(实测 chat→4023
model unknown、builder→4001、inline_chat→3003)。只把白名单用于"同模型择优"
的旧写法 = "目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,而错误文案
指向**模型**,极易被误判成"这个模型坏了"。现在不在表内的通道**整组丢弃**。

## ② 空档位不得覆盖有档位(issue IKI7WT/IKILR7)
旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据**相反**
—— 上游把空档位的 solo_work_lite/solo_design_remote 排在**最后**,实测 13 个
模型因此丢掉 reasoning_effort_config、全部显示"未提供推理等级"。
合并规则(与参考逐条对齐):空档位不得覆盖有档位;两侧都有档位时按通道优先级
取更靠前者;其余保持"后覆盖前"。

## ③ is_custom_model 是「仅可见但不可调用」的权威判据
该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。
⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志随服务端下发变化,
曾据名字把 qwen3.8-flash 误记为应剔除(它其实正常可用)。

## 附带:远端目录**元数据**不再丢
原来目录只回 id ⇒ 参考能提供的上下文窗口/输出上限/思考档位/消耗倍率全丢。
现按参考存下完整条目并在 model_list 以 `remote` 字段报出。
⚠ contextWindow 取 `context_window_tokens.dev` 而非 `max`:max 需开启
display_config.max_mode 才可用(我们不实现该开关),采信 max 会让上层以为有
1M 窗口、实际被上游拒。

## 基础设施
- refreshRemoteModels 支持 ep.body(POST 型目录端点)与 ep.sign(复用平台既有
  的头部构造,不另写一套 —— 否则"chat 能通、目录 401"极难查)。
- trae 走 sign="trae_solo" 复用 traeCheckinHeaders(Cloud-IDE-JWT + 约 20 个
  客户端头;参考实测简化头会被拒)。
- 新增 __trae_entry_probe(诊断"合并规则选中了哪条"),此前只能靠猜。

## 判据(3 条 + 变异 3/3)
internal/core/channels_trae_catalog_test.go
- 白名单准入 + 档位来源(空档位不得覆盖有档位)
- 四条硬性过滤(usage/config_switch/is_invisible_to_user/is_custom_model)
  且 **is_custom_model=false 的 custom_model_* 条目必须保留**(判据是标志值)
- 请求体必须传全部 22 个 function(与真实 IDE 一致;只传聊天通道会让非聊天
  通道条目位置错乱甚至被跳过)
变异:白名单不做准入 → 报红;无条件后覆盖前 → 报红;不过滤 custom → 报红。

★ 调试中发现并修掉我自己写的一个**静默失效**:
`declaresEffort` 读 `e.reasoning_effort_config` 而 `parseEntry` 存的是
`e.reasoning` ⇒ 判定恒为 false ⇒ 规则①永不生效(正是它要防的缺陷)。
判据当场抓到。**教训:跨函数用同一份数据时,字段名必须对齐 —— 这类不一致
不会报错,只会让规则静默失效。**

验证:internal/core 369s 全绿。
2026-10-16 08:28:36 +08:00
7c82c7ff6b fix(channels): account_clear_credentialless 尊重 dry_run(args=req 回退吃掉 body)
## 真实缺陷(我自己在上一个提交里引入,部署后实测踩到)
handler 开头照其它 18 处写了 `if args.platform == nil then args = req end`。
但本函数**不传 platform 是合法用法**(清全部平台)⇒ args.platform 自然为 nil
⇒ args 被换成 HandlerRequest ⇒ 刚解出来的 dry_run 就丢了 ⇒
**传 dry_run=false 却被当成未传,静默只预演**。

破坏性操作里这是最糟的形态:操作者以为执行了、实际没执行(反过来同样危险)。
实测:不带 platform 时 deleted 恒为 nil,池子一个没动。

## 修法
兜底只认「req 本身就是参数表」这种形态(`req.body == nil and req.query == nil`),
不再用「某字段为 nil」当判据 —— 那个字段为 nil 可能只是本次调用**合法地没用它**。

## ★ 判据为什么第一版没咬住
我原来的夹具用 **map body** 调 Call,而缺陷只在**字符串 body**(HTTP 真实路径)
上暴露 —— `bodyOf` 对 table 直接返回,根本走不到回退那行。
补 `TestAccountClearCredentiallessRespectsDryRunViaStringBody`(用
`InvokeHandler` + 字符串 body + 不传 platform)后变异才咬住。
**教训:夹具的调用形态必须与生产一致 —— 用 map 便捷调用会绕过 body 解析路径。**

判据 4 条(含本条)+ 变异 3/3。
2026-10-16 07:51:21 +08:00
1f18643d1f feat(channels): 无凭据账号的批量清理能力(dry_run 默认开启)
## 为什么需要
`account_add` 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
于是"先建占位、再走登录补齐"是正常用法。但登录没走完时(用户放弃、或历史批量
导入)会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
(面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"难以判断。

实测生产:13 个账号里只有 1 个真正可用 —— codearts 10 个 + trae 2 个全是占位
(同秒批量创建、nickname=id、凭据库里无对应条目)。

## 安全性(破坏性操作,两条性质都被变异验证钉住)
① **只删没有凭据的**:有凭据的一律不动(哪怕它测试失败 —— 那是"可疑"而非
   "空壳",处置完全不同);
② **dry_run 默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
③ 复用 `plugin.account_delete`(同一份删除实现)—— 删除时要顺带做的事
   (drop source、清旧单账号源、删凭据)不另写一套,否则必然漂移。

## ★ 踩到 Lua 顶层 local 上限(第 13 次同类)
helper 写成顶层 `local function accountHasCredential` 后 `luac -p` 直接报
`too many local variables (limit is 200)`。改为挂在 plugin 表上(`plugin.__hasCredential`)
—— 同样只有一份实现,且不占顶层名额。**每次给本文件加顶层 local 前必须 luac -p。**

## 判据(3 条 + 变异 2/2)
internal/core/channels_clear_credentialless_test.go
- 只删空壳、保留有凭据的,且**有凭据者的凭据必须仍在**(防误删)
- 指定 platform 时范围隔离(不动别的平台)
- 未知平台必须报错(否则会误删全部平台)
变异:无条件全删 → 报红;dry_run 默认改 false → 报红。
2026-10-16 07:45:52 +08:00
e6fa2a678f feat(zcode): 3012 判据/文案/诊断模块(照参考逐行移植,含对抗性审计反例)
照参考 zcode-diagnostics.ts 移植,作为**两处消费者共用的一份源码文本**:
① 插件主体(领取路径 claimZcode);② channels-zcode 适配器(推理路径)。
共用是必须的:3012 有**账号冷却惩罚**(30 分钟起步,反复触发升级到 24 小时乃至
停用),"请勿连续重试"这句话绝不能只在一条路径上(参考为此专门抽成单一份)。

## 为什么这次移植的不是"锦上添花"
我们原来的领取路径是 `code == 3012 or msg:find("unusual activity")`,而参考用
**对抗性审计**打出了两个方向的真实缺陷(2026-10-05,实测记录在源码注释里):

**误判方向**(把限流当风控):两者用户动作**完全相反**(稍后重试 vs 别重试),
且领取路径据此**短路**、跳过本可领取的 plan。实测反例:
  `too many requests, retry after 3012 ms`        ← 毫秒数被当风控
  `<h1>Error 3012</h1>upstream connect timeout`   ← HTML 里的数字
  `rate limited: request blocked, retry after 3012 ms`  ← block 距 14 字符
  `temporarily blocked; see error 3012`                 ← block 距 12 字符
  `plan-3012-trust` / `trust_3012` / `x3012y` / `13012` ← 标识符形态

**漏判方向**(真风控被放过):上游 3012 的 msg 原文
`request has been blocked due to unusual activity.` **不含任何数字**(数字在 code
字段里)⇒ "必须有独立词 3012"这条门禁会反过来把它漏掉。

⇒ 判据以**语义短语**为主、数字只作兜底且必须与 block **紧邻(≤10 字符)**;
词边界集必须含 `-`/`_`(参考两版才收全:先只排数字 ⇒ plan-3012 仍命中;
再补字母 ⇒ 仍命中,因连字符不属 [0-9A-Za-z])。
另:业务码认**纯数字字符串**(上游回 {"code":"3012"} 时,只认 number 会让风控码
整条丢失 ⇒ 用户看到那句裸上游串)。

## 一并移植的其它实测教训
- `formatGap`:分钟档必须**逐档单独算**。参考初版写 `floor(seconds % 60)`,
  90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)——该错误是在真实探针
  输出里发现的,纯函数单测当时没覆盖 ≥60 秒区间。
- 诊断的「距上条」必须用**单独存的 previousSentAt**,不能拿 lastSentAt 顶替
  (调用顺序是"先记本次、再诊断",顶替会让它恒为 0ms、永远没有信息量)。
- 形态诊断必须读**真正要发出去的那个对象**,不读常量:常量只能证明"源码里写了
  多少字",证明不了"线上发出去多少字";空 system 时必须**如实显示 0**。
- 诊断行**绝不写凭据**:账号只以进程内自增序号出现。

## ★ 接线上的一个真实 bug(我自己引入并自查出来)
`installZcodeAdapter` 最初把 DIAG 源码直接 `.. ZCODE_ADAPTER_SRC` 拼接 ——
而 DIAG 末尾是 `return Z`,直接拼会让那个 return 结束**整个 chunk** ⇒ 适配器
代码永不执行,且**装载会静默成功**("加载成功但适配器不存在")。
修法:包进 `(function() ... end)()` 让 return 只结束该函数。

## 判据
`internal/core/channels_zcode_3012_test.go`(走共用探针打**那一份**实现):
- 19 条分类矩阵(含上列全部审计反例)
- formatGap 逐档(含 90000ms 必为「1分30秒」)
- 诊断行凭据泄漏守卫(账号 id 的每个 ≥4 字符子串都不得出现)
- 文案共用 + 必含「请勿连续重试」+ 空参不留空壳
- 形态诊断读实际对象(空 system 必须显示 0)

★ 变异 2/2 咬住。其中分类矩阵**第一版没咬住** —— 因为"紧邻检查"独立挡住了
那些用例,从未**隔离**词边界规则;补 5 个"block 紧邻但 3012 在标识符里"的
用例后才有效。教训:**必须有一组用例只被被测规则区分**,否则别的守卫会把它
的失效掩盖掉(本轮第 4 次"判据太弱")。

验证:internal/core 351s 全绿。
2026-10-16 01:38:34 +08:00
b871c0c713 fix(zcode): 日期块对齐参考的两条规则(幂等 + 只看第一条),修 off-by-one
通读参考 zcode-identity.ts 的 withContextPrefix 后发现我的实现漏了两条官方行为,
且其中一条藏着真实 bug:

## ① ★ 幂等判断 off-by-one(真 bug)
`"<system-reminder>"` 是 **17** 个字符,而我写的是 `c:sub(1, 16) == SR`
—— 判断**永远为假** ⇒ 幂等完全失效:同一请求重试/多次 transform 时日期块
会叠加成多层,改变 content 的前缀结构(上游检查的正是这个结构)。
修法:用 `#SR` 算长度,不写死数字。

## ② 只看**第一条**消息,且它必须 role == "user"
我原来实现成「往后扫第一个 user 消息」。官方行为是:只处理 `messages[0]`,
且若它不是 user 就**完全不动** —— 不是去找后面那条 user。已对齐。

## ③ 其它类型兜底
参考对非字符串/非数组的 content 也会置为 `[prefix]`,照做。

判据 2 条(新增):
- TestZcodeContextPrefixIsIdempotent     已含日期块时不重复插
- TestZcodeContextPrefixOnlyTouchesFirstMessage  首条非 user 时不动、首条 user 时插入
变异 2/2 咬住:把 #SR 改回写死的 16 → 报红;去掉幂等判断 → 报红。

★ 我自己在写这条判据时又犯了一个错并当场自查出来:断言「文本 content 应保持
字符串」,而 anthropic 基座把文本转成块数组是**正常**的协议转换行为 —— 差点把
正确的实现判成错。判据要验的是「**日期块**有没有被插进来」这个事实,不是
content 的容器类型。

验证:internal/core 335s + internal/gateway 207s 全绿。
2026-10-16 01:13:11 +08:00
8b318a8507 feat(zcode): 身份块适配器 + 修复调度器泄漏 + codearts 目录 + 备份裁剪泛化
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:

    无 system                        → 3012 ✗
    仅 cliPrefix(42 字符)           → 3012 ✗
    cliPrefix + stable(约 2898 字符)→ 200  ✓

⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。

实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
  block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
  / block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
  只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
  日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。

判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。

## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。

## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
  ① opengw.../gateway/config  → result.models(benefit)
  ② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。

## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。

## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。

## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。

清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
2026-10-16 00:56:14 +08:00
69da3c9b92 fix(kernel): Close() 从不调 on_unload —— 孤儿进程与回调文件泄漏
## 症状
本机积累 **995 个 llmsproxy-loopback.py 进程**(828 个超 24 小时),全是
/tmp/Test*/ 与 .build-work/Test*/ 下的测试残留;生产 /etc/llmsproxy/plugins/
另有 **18 个孤儿 callback-*.port**(对应 .json 早已消失),且永远清不掉。

## 真因(两层)
1. **回环回收本是惰性的**:reapLoopback 只在**会话过期**时被 sweepSessions
   触发。测试跑完直接退出 ⇒ 会话从未「过期」⇒ 进程常驻。生产会话真会过期,
   所以生产不漏进程 —— 但见第 2 条。
2. **★ 根因在内核**:Plugins.Close() 只关 shard 与 saver,**从不调用
   on_unload** —— 而 on_unload 只在**单插件 Unload** 时调。于是「进程关闭/
   测试结束」这条路径上,起了 OS 进程的插件永远收不了尾。插件的
   plugin.on_unload(回收浏览器栈 + 回环)写得完全正确,只是从不被调用。
   这是「两处只修一处」缺陷的又一例:Unload 有、Close 没有。

## 修法
- 抽 invokeOnUnload(p, name) 助手,**Unload 与 Close 共用同一份实现**
  (两处各写一遍必然漂移,本次缺陷形状正是如此);Close() 新增
  callOnUnloadAll()。
- ★ 顺序:shards.Shutdown() → save.Close()(落盘)→ callOnUnloadAll()。
  落盘必须在前 —— saver 的最终写入要读插件的 Lua 表,on_unload 之后 state
  已关,读到的就是空,最后一个区间的累积静默丢失(TestCloseFlushesTail 正是
  为守住这一点而存在)。我第一版把顺序写反,被该测试当场咬住。
- channels:启动清扫上一进程留下的孤儿 callback-*.port/.json。loopbacks
  追踪表只活在内存,进程被强杀时 on_unload 没机会跑,重启后内存表为空 ⇒
  reapLoopback 无从知晓 ⇒ 那 18 个文件永久残留。
- channels:补 reap_loopbacks 端点(生产运维也可用:不必等会话过期)。
- channels:__set_loopback_probe 从**覆盖**改为**合并**条目 —— 覆盖会丢掉
  真实会话的 pid/port_file,让 reapLoopback 杀不掉接收器,**探针自己成为
  泄漏源**(实测:其他测试已归零,只有它留 1 个孤儿)。

## 判据(均做变异验证)
- internal/lua/plugin_unload_on_close_test.go
    TestPluginsCloseCallsOnUnload      看真实外部效应(插件在 on_unload 写磁盘
                                       marker),而非「钩子被调用过」这类内部信号
    TestPluginsCloseSurvivesFailingOnUnload  best-effort 契约
- internal/core/channels_orphan_callback_test.go
    TestPluginLoadSweepsOrphanCallbackFiles  看目录真实状态:孤儿必须消失,
                                       非回调文件(.channels-store.json)不得误删
- 变异均咬住:注释掉 callOnUnloadAll → 报红;清扫改 no-op → 报红。

## 实测
修复前跑一次起会话的测试批次留 7×N 个残留进程 → 修复后 **0 个**;全量回归
(go test -tags luajit ./... 10 包)后回环/VNC 残留均为 0。

## 过程中自查出的两处疏漏(已修)
1. 判据顺序又写反一次(同上 TestCloseFlushesTail 咬住)。
2. **两枚变异残渣留在工作区**未还原:internal/gateway/discover.go 里
   `add(raw, "/models", 0)` 被删、`classifyAuthStatus` 的 `|| len(body) >= 0`
   恒真注入。已 git checkout 还原。教训:变异测试必须逐次收尾,别靠记忆。
2026-10-15 22:43:08 +08:00
1be34557dd fix(trae): 登录失败真因 —— 虚构的「授权码交换」+ 空 client_id
用户报「TRAE 登录后显示失败」。定位到三处偏差,其中一处是核心缺陷:

① ★ **虚构了一次不存在的「授权码交换」**(核心)
   poll_login 的 trae 分支自己发明了一段逻辑:回调只带 code 时,拿
   exchange_body 去打 ExchangeToken。但:
     · ExchangeToken 的签名是 RefreshToken,不是 code;
     · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
     · client_id 当时是空串。
   于是稳定 400,日志回一句误导性的「Trae 授权码交换失败: 400」——
   把真因「上游走了 PKCE 流程」掩盖成「交换出错了」,排查方向完全错。

   按参考实现(trae-oauth.ts parseTraeCallbackDetailed)修正:带 code 的
   回调是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
   删除虚构交换,两条路径共用 traeCallback 内建的 PKCE 判定。

② client_id 从空串改为实测真值 en1oxy7wnw8j9n(对齐参考 trae-product.ts
   的 clientId;ExchangeToken 请求体里 ClientID 必填,留空稳定 400)。

③ 两条路径(oauth_callback / poll_login)现在共用同一结论,不再漂移 ——
   这正是缺陷能藏住的原因:修复只覆盖一条、另一条继续错。

测试支撑:
- 新增 __set_loopback_probe(注入 loopback 回调文件),否则无法驱动
  poll_login 的 callback 分支(那才是真实登录走的路径)。
- 判据 4 条,全部做变异验证:
    TestTraePkceCallbackIsReportedNotExchanged  打 poll_login,断言没打 ExchangeToken 且点名 PKCE
    TestTraeExchangeSendsRealClientID           token 直传真调交换,请求体形状正确
    TestTraeClientIDIsNotBlank                  client_id 必须是实测真值
    TestTraeCallbackPathsAgreeOnPkce            两条路径必须共用 traeCallback
- 变异 2/2 咬住:client_id 改回空串 → 报红;虚构交换加回去 → 「实际打了 1 次
  ExchangeToken」。

★ 我在过程中自己犯的两个错(均由变异测试揭穿,已修正):
1. **判据打错了路径** —— 第一版打 oauth_callback(那条一直是对的),注入缺陷后
   判据**仍绿**。改成打 poll_login 才咬住。教训:判据必须打**真实被使用的那条
   路径**,否则等于没测。
2. **判据写错了契约** —— poll_login 用 HTTP 200 + json.state 表达终态,我按
   status != 200 断言,把正确的实现判成了错。

回归:go test -tags luajit ./... 10 包全绿。
2026-10-15 22:04:01 +08:00
191 changed files with 5301 additions and 26177 deletions

497
HANDOFF_2026-10-19.md Normal file
View File

@ -0,0 +1,497 @@
# ModelRouter / llmsproxy 交接文档
更新时间:2026-10-19
工作目录:`/home/program/llmsproxy`
当前分支:`release/v2.0.x`
## 1. 当前 Git 状态
最近提交:
- `c9f3453 fix(codearts): 识别 InferHub 未注册模型`
- `12bfd53 fix: 收口渠道源迁移与测试页合并`
- `25970a2 refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据`
`c9f3453` 已推送到 `origin/release/v2.0.x`。
注意:当前工作树在最后一轮添加 provider 回归测试时出现了一个**未完成且可能无法编译的测试编辑**。最后一次工具报错:
```text
internal/provider/provider_test.go:99: missing ',' in argument list
```
下一位 agent 接手后的第一步必须是:
```bash
cd /home/program/llmsproxy
git status --short
gofmt -w internal/provider/provider_test.go 2>&1 || true
go test -tags luajit ./internal/provider/ 2>&1
```
如果测试文件处于半编辑状态,先检查 `TestProviderChatStreamNormalizesStopAfterToolCall`,修正 Go 字符串转义或删除该未完成测试后再继续。不要直接部署当前工作树。
## 2. 生产服务状态
生产服务:`llmsproxy.service`
- 当前服务 active
- 监听:`0.0.0.0:8081`
- 二进制已通过 `./deploy.sh` 原子替换并重启
- 最近一次部署健康检查通过
- 当前生产插件 SHA256 前缀曾核对为:`1bf26cbb3abcb107`
- 当前生产二进制在最近部署后已更新
不要把密钥写入交接记录。生产 admin key 保存在本机运维环境中,不应复制到聊天、文档或提交。
## 3. Channel 源当前状态
生产源已经从旧的“一账号一源”收口成“一渠道一源”:
```text
channels-trae
channels-minimax
channels-lobsterai
channels-buddy
channels-codearts
channels-zcode
```
旧形态源已经清理:
```text
channels-<provider>-<account_id>
```
此前旧源和新源并存,造成同一渠道同一模型出现多份,只能靠账号 hex 区分。
根因已修复:内核 `list_sources` 返回裸数组,但旧插件代码按 `{sources = [...]}` 信封读取,导致迁移清理循环永远为空。当前 `channels.lua` 已兼容:
- 裸数组
- `{sources = [...]}`
- `{json = {sources = [...]}}`
相关代码:
```text
internal/lua/plugins/channels.lua: syncProviderSource
internal/core/channels_legacy_source_cleanup_test.go
```
当前生产源列表已实测只有 6 个新形态源。
## 4. CodeArts 当前状态
### 已确认可用的部分
CodeArts 使用:
```text
adapter = codearts
base_url = https://snap-access.cn-north-4.myhuaweicloud.com
```
CodeArts 的 chat 请求需要完整 AK/SK/security token 凭据,由插件适配器签名,不是裸 Bearer token。
真实流式工具调用测试已经成功:
请求形状:
```json
{
"model": "channels-codearts:openpangu-2.0-flash",
"messages": [{"role": "user", "content": "Use the weather tool for Beijing."}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}],
"tool_choice": "auto",
"stream": true,
"max_tokens": 64
}
```
结果:
- HTTP 200
- `Content-Type: text/event-stream`
- 返回结构化 `tool_calls`
- 函数名 `get_weather`
- 参数最终为 `{"city":"Beijing"}`
- `finish_reason = tool_calls`
因此 CodeArts 的流式工具调用链路当前是通的。
### CodeArts 模型刷新
生产调用:
```http
POST /api/plugins/http/channels/model/refresh
{"platform":"codearts"}
```
实测返回:
```json
{
"ok": true,
"results": [
{"platform": "codearts", "ok": true, "count": 11}
]
}
```
CodeArts 模型目录来自两个签名端点并合并:
1. `opengw.developer.huaweicloud.com/api/v1/gateway/config`
- `result.models`
2. `snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin`
- `builtinModels`
相关解析:
```text
internal/lua/plugins/channels.lua: parseRemoteModels
internal/lua/plugins/channels.lua: refreshRemoteModels
internal/core/channels_codearts_catalog_test.go
```
### 已确认失效并禁用的 CodeArts 模型
下面 4 个模型曾收到明确的上游 404:
```text
CodeArts-Pangu
deepseek-v4-flash-0731
deepseek-v4-pro-0813
glm-5.3-flash
```
生产上已通过 `channels/model/set_disabled` 禁用。它们仍可能在 `model/list` 中出现,但 `disabled=true`,不会作为可用模型路由。
新增的死模型判据:
```lua
model is not registered
```
对应提交:`c9f3453`。
注意:当前插件的 dead-model 机制只在收到真实请求失败后记录模型;刷新目录不会自动根据“远端目录没有该模型”删除模型,这是刻意设计,避免误删远端目录不完整的平台模型。
CodeArts 当前仍有一些模型返回 429/TPM 限流,例如:
```text
deepseek-v4-flash
deepseek-v4.1-flash
```
429 不是模型失效,不能禁用。
## 5. 其他 Channel 实测结果
### Trae
真实流式 + tools 请求:
```text
channels-trae:DeepSeek-V4-Flash
```
结果:
- HTTP 200
- SSE 正常返回
- 结构化 tool call 返回
- 但上游最后的结束原因可能是 `stop`,即使前面已经出现 tool-call fragments
这会让 Agent 把工具调用误判成普通文本完成。
当前准备中的修复在统一 provider 流式层:
```text
internal/provider/provider.go
```
逻辑是:只要本次流出现真实 tool-call 片段,后续 `FinishReason == "stop"` 就归一成 `tool_calls`。
另外,Trae 的非流式接口偶尔返回 SSE。当前 `trae.lua` 对非流式 SSE 只取最后一条 data,不能完整合并多条工具调用/推理片段。这是后续仍需处理的协议问题,但不能用非流式手工请求结果推断 Pi 的真实请求模式。
### Buddy
真实流式 + tools 请求:
```text
channels-buddy:glm-5.3-flash
```
结果:
- HTTP 200
- SSE 正常返回
- tool-call 参数分片可见
- 最终有 `finish_reason = tool_calls`
- 但某些上游 chunk 的 `tool_calls` 可能是空对象 `{}`
空对象不是合法的 OpenAI tool-call 数组,部分 Agent 客户端会拒绝。
当前准备中的修复在:
```text
internal/lua/adapters/buddy.lua
```
只在 `type(delta.tool_calls) == "table" and #delta.tool_calls > 0` 时输出 `tool_calls`,避免 Lua 空表被编码为 `{}`。
Buddy 的非流式错误:
```text
11101: Non-stream chat request is currently not supported
```
之前出现的该错误来自手工 `curl` 测试中显式设置 `stream:false`,不能归因到 Pi。真实 `stream:true + tools` 已经证明 Buddy 流式工具调用可以工作。
### MiniMax
生产日志反复出现:
```text
adapter "anthropic": status=401 body={"error":"invalid access token"}
provider channels-minimax: api error 401
```
这是凭据失效问题,不是 tools 形状问题。
当前源:
```text
channels-minimax
adapter = anthropic
endpoint = /mavis/api/v1/llm/v1/messages
```
需要用户重新登录/提供有效凭据。不要擅自刷新一次性 refresh token,之前已经发生过 refresh token 被消耗后无法再次使用的事故。
## 6. 当前未提交的代码改动
最后一轮已经改动但尚未完成提交:
```text
internal/provider/provider.go
internal/lua/adapters/trae.lua
internal/lua/adapters/buddy.lua
internal/provider/provider_test.go # 可能有半编辑、需先修复
```
已完成的意图:
1. provider 流式层记录 `sawToolCalls`
2. 看到真实 tool-call 后,把后续 `stop` 归一为 `tool_calls`
3. Trae 不输出空工具调用表
4. Buddy 不输出空工具调用表
5. provider 回归测试模拟“tool fragment + stop”场景
但 provider 测试最后一次编辑工具报错:
```text
internal/provider/provider_test.go:99: missing ',' in argument list
```
因此必须先修测试文件,再提交。
## 7. 推荐接手顺序
### 第一步:修复工作树
```bash
cd /home/program/llmsproxy
git status --short
sed -n '1,150p' internal/provider/provider_test.go
gofmt -w internal/provider/provider_test.go 2>&1 || true
go test -tags luajit ./internal/provider/ 2>&1
```
若测试函数字符串转义难以修复,先删除半成品测试,再重新用 raw string 写测试。
### 第二步:跑定向验证
```bash
luac -p internal/lua/adapters/trae.lua
luac -p internal/lua/adapters/buddy.lua
go test -tags luajit ./internal/provider/
go test -tags luajit ./internal/lua/ -run 'Adapter|Buddy|Trae|Stream'
```
### 第三步:构建并部署临时验证版
修复确认后:
```bash
go build ./...
./deploy.sh
```
部署前确认:
```bash
sha256sum internal/lua/plugins/channels.lua
sha256sum /etc/llmsproxy/plugins/channels.lua
systemctl is-active llmsproxy
```
### 第四步:真实流式回归
不要用 `stream:false` 测试流式问题。使用:
```bash
KEY='<从本机安全环境读取,不写入文档>'
TOOL='{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}'
curl --no-buffer --max-time 90 \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d "{\"model\":\"channels-codearts:openpangu-2.0-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"Use the weather tool for Beijing.\"}],\"tools\":[$TOOL],\"tool_choice\":\"auto\",\"stream\":true,\"max_tokens\":64}" \
http://127.0.0.1:8081/v1/chat/completions
```
分别将 model 替换为:
```text
channels-codearts:openpangu-2.0-flash
channels-trae:DeepSeek-V4-Flash
channels-buddy:glm-5.3-flash
```
验收:
- HTTP 200
- `Content-Type: text/event-stream`
- 不能出现 `tool_calls: {}`
- 有 tool-call fragments 时最终必须是 `finish_reason: tool_calls`
- CodeArts 当前应继续返回结构化工具调用
### 第五步:CodeArts 刷新验收
```bash
curl -sS -X POST \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"platform":"codearts"}' \
http://127.0.0.1:8081/api/plugins/http/channels/model/refresh
```
预期:
```json
{"ok":true,"results":[{"platform":"codearts","ok":true,"count":11}]}
```
再检查:
```bash
curl -sS -X POST \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"platform":"codearts"}' \
http://127.0.0.1:8081/api/plugins/http/channels/model/list
```
预期:远端目录 11 个 + 兜底补位,已确认 404 的 4 个仍保持 `disabled=true`。
## 8. 发布/安装包状态
目前没有完成“全部安装包”发布闭环。
已经发生的事情:
- 核心包构建成功,但脚本实际使用了 `git describe`,产物版本类似:
`2.0.40~4-gc9f3453`
- Linux GUI 包构建成功,版本为 `1.9.7`
- Windows 主机 Wine 构建失败,生成了一个 264KB 的坏 `ModelRouter Setup 1.9.7.exe`,绝对不能发布
- Docker Windows 构建也失败在拉取 Docker Hub 基础镜像 `golang:1.25` 网络超时
- apt 无法找到 `wine32:i386`
- 旧的 1.9.5/1.9.6 Windows 包是历史产物,不能作为当前版本
不要把旧 Windows exe 当作新包发布。
仓库已有 Docker 方案:
```text
cmd/gui/scripts/dist-win-docker.sh
cmd/gui/docker/win-builder/Dockerfile
```
但当前 Docker Hub 网络不可用。若要继续做 Windows 包:
1. 在可访问 Docker Hub 的构建机/CI 执行 Docker 方案;或
2. 提供包含 Wine32/Wine64 的完整构建环境;或
3. 暂时只发布 Linux/core 包,并明确说明 Windows 包未完成。
版本脚本注意:
- 核心包脚本使用位置参数:
```bash
./packaging/core-dist.sh 2.0.41
```
不是 `VERSION=2.0.41 ./packaging/core-dist.sh`。
- GUI 版本来自 `cmd/gui/package.json`,环境变量 `VERSION` 不会自动修改 Electron 产品版本。
- 清理旧产物后再打包,并在全部包完成后统一计算 `SHA256SUMS`。
## 9. 参考插件状态
用户要求按:
```text
https://github.com/Ebony-Vinyl/dsh-our-free-model
```
逐厂商对照参考插件。
当前本地路径:
```text
/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model
```
但该目录当前不是有效可读源码副本:
- `vendor/channel-pack/src` 下面只有空目录;
- `.git` 没有有效 `HEAD`/refs;
- 无法从本地 Git 对象恢复源码;
- 本地也没有 `buddy.ts`、`trae.ts`、`codearts.ts` 等文件。
因此不能声称当前适配器是逐行照参考插件移植的。若要继续按用户要求完成,必须先重新 clone/恢复该 GitHub 仓库源码,确认以下文件实际存在后再对照:
```text
vendor/channel-pack/src/trae.ts
vendor/channel-pack/src/buddy.ts
vendor/channel-pack/src/codearts.ts
vendor/channel-pack/src/*adapter*
vendor/channel-pack/src/*model*
```
当前仓库代码只能作为临时协议修复基础,不能代替参考插件源码。
## 10. 关键结论
- Pi 是否发送流式请求,历史日志没有保存入站 `stream/tools` 字段,不能靠旧日志判断。
- 本次真实 `stream:true + tools` 测试证明 ModelRouter 流式路径能工作:
- CodeArts:通过;
- Trae:通过但结束原因需归一;
- Buddy:通过但需过滤空 `{}`;
- MiniMax:凭据 401,独立问题。
- 不要再用 `stream:false` 的手工请求推断 Pi 的问题。
- CodeArts 刷新接口当前真实返回 `ok=true,count=11`,刷新链路不是当前阻塞点。
- 当前最紧急的代码状态是修复 `provider_test.go` 的半编辑,然后验证未提交的统一流式修复。
- 未经测试、未重新构建、未重新部署,不要继续改生产账号或刷新一次性凭据。

View File

@ -1,8 +1,8 @@
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win gui-win-docker help clean gui-test
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
export BINARY
BINARY=llmsproxy
BINARY=modelrouter
export BUILD_DIR
BUILD_DIR=build
export GUI_DIR
@ -20,7 +20,7 @@ all: build
# ---- core binary (server users / embedded core) ----
build:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/llmsproxy
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/modelrouter
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
# ---- core distribution packages (deb + rpm + tar.gz) -----
@ -65,6 +65,13 @@ gui-dist-dir:
test:
go test ./...
# Desktop shell unit tests (no Electron needed): plugin-tree seeding/migration.
# These guard the two things that silently break the desktop build:
# - plugin_dir missing from an embedded profile -> channels plugin invisible
# - bundled plugin files never refreshed -> old channels.lua survives upgrades
gui-test:
@cd $(GUI_DIR) && node --test plugin-sync.test.js
clean:
rm -rf $(BUILD_DIR) cmd/build cmd/gui/dist cmd/gui/build/gui-dist cmd/gui/bin
@ -75,6 +82,7 @@ help:
@echo " make gui run desktop GUI (dev)"
@echo " make gui-dev run GUI with devtools"
@echo " make gui-dist build deb + rpm + AppImage (linux)"
@echo " make gui-test desktop shell unit tests (plugin tree seeding)"
@echo " make gui-deb build deb only"
@echo " make gui-win build Windows nsis (needs host mingw + wine)"
@echo " make gui-win-docker build Windows nsis all-in-docker (no host mingw needed)"

676
README.md
View File

@ -1,106 +1,51 @@
# ModelRouter
> **仓库**: <https://atomgit.com/HeNanMU-EEA/ModelRouter>
>
> **English**: [README_EN.md](./README_EN.md)
>
> **AI-assisted**: 本项目使用 AI 辅助编程(代码与文档由 AI 协作完成,经人工复核)。
面向多 llms 订阅者,部署在内网,实现一次配置多个服务共同使用的效果。支持指定模型或 auto 模式,按照配置的优先级选择可用模型提供服务。
面向多 LLM 订阅者的内网网关:一次配置,多个服务共用一组上游额度。支持指定模型或
`AUTO` 模式,按配置的优先级选择可用模型提供服务。
> **轻量且增源无需重编译**:网关本体是单 Go 二进制(约 10MB,零运行时依赖)。新增/切换上游
> 只需在 `config.yaml`(或 WebUI)加一个 `sources` 条目或挂一个 `.lua` 适配器——**不改 Go、
> 不重编译**。源与适配器的改动经 WebUI 提交时即时生效(热更新);直接编辑
> `config.yaml` 或 `adapter_dir` 下的 `.lua` 文件则需要重启进程生效。
网关本体是单个 Go 二进制(约 10 MB,零运行时依赖)。新增或切换上游只需在
`config.yaml`(或 WebUI)加一个 `sources` 条目、或挂一个 `.lua` 适配器——**不改 Go、
不重编译**。源与适配器的改动经 WebUI 提交时即时生效(热更新);直接编辑
`config.yaml` 或 `adapter_dir` 下的 `.lua` 文件需要重启进程生效。
## 核心优势
## 核心能力
### 极致轻量
- **单二进制**:编译后 8~12MB(`-s -w` strip 后约 8MB),零运行时依赖(仅依赖系统 libc),部署即用
- **低内存占用**:与源数量相关,非与运行时长相关——单源 ~10MB、16 源生产实例 ~32–35MB([实测分解与调优](#内存占用实测与调优))
- **日志按需加载**:统计数字来自**全量**审计日志(启动时流式扫一遍即释放,29MB/22 万行约 260ms),但原始记录不常驻内存——默认只加载首屏,下滚自动分页,CSV 导出流式写出(O(1) 内存),离页即释放
- **零运行时依赖**:纯 Go + LuaJIT 静态链接,无需安装 Python/Node/Java 等运行时
- **启动极快**:冷启动 < 200ms,热重载配置 < 10ms
### 强大的多租户调度能力
- **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期
- **按模型配额**:每把 key 的每个模型单独配 token + 请求数配额与重置周期(小时/周/月/自定义 N 小时);一个模型用满只影响该模型,同 key 其它模型照常;耗尽返 429 + `Retry-After` 并点名模型,admin key 永不受限
- **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移
- **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口
- **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置
- **同优先级源负载均衡**:同一优先级档位的多个源,请求自动 Round-Robin 均匀分发,故障毫秒级故障转移
- **Source-Model 前缀路由**:支持 `source-model`/`source:model`/`source/model` 精确指定上游源
### 生产级可靠性
- **热加载配置**:WebUI 修改源/密钥/适配器/AUTO链即时生效,无需重启
- **密钥加密落盘**:AES-256-GCM 加密存储 `api_key`、网关密钥、请求头,master.key 0600 权限保护
- **实时源探测**:`GET {base}/models` 定期探测,毫秒级感知上游状态,不污染调度退避状态
- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出
### 灵活的协议适配
- **LuaJIT VM**:每个适配器独立 VM + **弹性 worker 池**(按需扩缩容,空闲时自动回收),安全并发,Lua 脚本热加载无需重启
- **Lua 适配器协议**:`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换
- **动态请求头钩子**:`build_headers(meta)` 支持 HMAC 签名、动态 Header 注入
- **多模态透传**:`image_url` 等多模态内容在多源间无损透传,Anthropic/Gemini/Ollama 自动转换
## 实现
统一 OpenAI 兼容网关:把多个上游 LLM 源(DeepSeek、Qijiar、OpenAI、Anthropic、Gemini、
Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换,对内网暴露
一个标准 `OpenAI Chat Completions` 接口(`/v1/chat/completions` + `/v1/models`),
支持单次结算、SSE 流式、**AUTO 模型路由**与**多模态**透传。
从 [HomeAgent](https://gitcode.com/JianFeeeee/HomeAgent) 的多源 LLM 适配层
(`internal/agent/api/provider.go` + `internal/lua/adapters/*`)抽离而来并独立演进。
## 特性
- **多源**:一个进程内配置任意多个上游源,按请求的 `model` 自动路由。
- **AUTO 模式**:`default_model: AUTO` 时按优先级页保存的 AUTO 链档位逐档调度并发请求。
- **统一输出**:所有源对外都是 OpenAI 格式(含 `reasoning_content`、`tool_calls`、`usage`)。
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
池是 **弹性** 的:`Σ max_concurrent` 只是上限而非预分配,状态按需创建、空闲时自动回收,
因此空载网关几乎不持有 Lua 状态(启动 0 个)。
- 扩容步长由适配器最大并发决定:`clamp(ceil(max/8), 1, 8)`,且仅在**真实并发争用**
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
- **thinking 预算**:请求里的推理开关/预算归一为中立的 `thinking:{enabled,budget_tokens}`,
适配器映射到各自协议(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig`、
DeepSeek `thinking.type="disabled"`)。
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
- **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前
动态注入/签名请求头——用于云端 API 校验调用方 app(如 KimiCode 只放行特定
agent)。提供 `hmac_sha256_hex` / `sha256_hex` / `base64_encode` 等签名辅助。
- **WebUI**:内置管理界面,可在线查看/新增/编辑上游源与模型、配置 AUTO 优先级链、
管理密钥模型范围,写入运行时文件持久化。
- **密钥加密存储**:运行时文件中的上游 `api_key`、自定义请求头值、网关 key 均以
AES-256-GCM 加密落盘(`master.key` 独立 0600,或 `LLMS_PROXY_MASTER_KEY`)。
- **实时源探测**:状态页探测各源可达性(`GET {base}/models`,失败回退最小请求),
不污染正常调度的退避状态,错误信息在 UI 可悬停查看。
- **鉴权**:网关自身用 `gateway_keys` 校验客户端 Bearer key;与上游各自的 key 相互独立。
- **流式**:SSE `chat.completion.chunk`,含角色首包与 `[DONE]` 收尾。
- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出
- **AES-256-GCM 加密存储**:运行时文件敏感字段加密落盘,master.key 0600 权限,支持环境变量注入主密钥
- **统一接口**:对内暴露 OpenAI 兼容的 `/v1/chat/completions`、`/v1/models`、
`/v1/images/generations`;所有源对外都是同一套格式(含 `reasoning_content`、
`tool_calls`、`usage`)。
- **多源 + AUTO 链**:一个进程内任意多个上游源;`default_model: AUTO` 时按优先级页
保存的档位逐档调度,源故障自动降级并冷却,恢复后半冷却探测自愈。
- **协议适配在 Lua 层**:每个源挂一个 `.lua` 适配器,完成请求/响应/流式分片的双向
转换,并可用 `build_headers(meta)` 注入签名头。协议差异不进 Go 内核。
- **插件独立**:业务扩展(计费、用量统计等)是外置 Lua 插件,由插件目录加载,
不编进二进制、不随发行包分发;插件只通过 `modelrouter` SDK 与内核交互。
- **多租户密钥**:网关 key 与上游 key 相互独立,按 key 限定模型范围、AUTO 链与配额。
- **加密存储**:上游 key、自定义请求头、网关 key 均以 AES-256-GCM 落盘,
`master.key` 单独 0600 权限(或由 `MODELROUTER_MASTER_KEY` 注入)。
- **可观测**:状态页实时探测源可达性;HTTP 访问日志、配置变更审计、请求与用量 CSV 导出;
写操作不落在请求热路径上。
## 快速开始
```bash
GOMODCACHE=... GOPROXY=off go build -tags luajit -o llmsproxy ./cmd/llmsproxy
./llmsproxy -config config.yaml # 首次运行自动生成默认配置并打印随机 admin key
# 需要 LuaJIT 开发库;-tags luajit 是必须的
GOMODCACHE=... GOPROXY=off go build -tags luajit -o modelrouter ./cmd/modelrouter
# 首次运行会生成默认配置,并在日志里打印随机 admin key
./modelrouter -config config.yaml
```
> 依赖 [golua](https://github.com/aarzilli/golua)(LuaJIT 绑定)。**必须**带 `-tags luajit`
> 构建,否则默认走内置超集 gopher-lua 路径(行为略有差异)。
> 依赖 [golua](https://github.com/aarzilli/golua)(LuaJIT 绑定)。不带 `-tags luajit`
> 会走 gopher-lua 路径,行为略有差异。
> **配置不入库**:仓库不携带任何 config 文件(配置文件含密钥)。二进制首次运行时
> 会在 `-config` 指定的位置生成默认配置:随机 admin key(打印在启动日志中)、
> 仅绑定 `127.0.0.1:8080`。首次登录后请在 WebUI「密钥」页更换管理员密钥。
> **配置不入库**:仓库不携带任何 config 文件(其中含密钥)。默认配置只绑定
> `127.0.0.1:8080`,首次登录后请在 WebUI「密钥」页更换管理员密钥。
```bash
# 无 key -> 401
@ -117,526 +62,73 @@ curl -N -H "Authorization: Bearer $KEY" \
http://127.0.0.1:8080/v1/chat/completions
```
任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用任意一个已创建并授权的网关 key 即可。
任何 OpenAI SDK 把 `base_url` 指向网关、`api_key` 换成任一已授权的网关 key 即可。
## 配置
## 文档
首次运行在 `-config` 路径自动生成默认配置(默认 `./config.yaml`),核心字段:
| 主题 | 文档 |
|---|---|
| 实现与完整特性清单 | [docs/features.md](docs/features.md) |
| 配置:`config.yaml`、自动探测模型、多密钥 | [docs/configuration.md](docs/configuration.md) |
| 路由:模型路由、AUTO 链、冷却与半冷却、源与适配器、源模板 | [docs/routing.md](docs/routing.md) |
| 适配器:Lua 协议、内置适配器清单 | [docs/adapters.md](docs/adapters.md) · [docs/lua-adapters.md](docs/lua-adapters.md)([EN](docs/lua-adapters-en.md)) |
| 插件:SDK 契约、钩子、UI 扩展、HTTP 端点 | [docs/plugins.md](docs/plugins.md) |
| 桌面端:Headless / Desktop 两种产物、构建与运行 | [docs/gui.md](docs/gui.md) |
| 性能:实测内存占用与调优、thinking 预算 | [docs/performance.md](docs/performance.md) |
| WebUI 使用 | [docs/webui.md](docs/webui.md) |
| 开发:目录职责、测试、分支与发布纪律 | [docs/development.md](docs/development.md) · [docs/git-workflow.md](docs/git-workflow.md)([EN](docs/git-workflow-en.md)) |
| 部署与迁移 | [docs/deployment.md](docs/deployment.md) |
```yaml
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
gateway_keys: [sk-gw-<随机>] # 初始 admin 密钥(每次安装随机生成,仅首启写入运行时存储用)
default_model: AUTO # model 无法路由时自动按优先级链选源
adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读
runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件
## 从 llmsproxy 改名
sources:
- name: deepseek
base_url: https://api.deepseek.com
api_key: sk-... # 也可用 api_key_env: SOME_ENV 引用环境变量(不落盘明文)
adapter: deepseek
max_concurrent: 8
models:
- id: deepseek-v4-flash
priority: 100 # YAML 源首次启动会 seed 进 AUTO 链的初值
kind: chat
- id: deepseek-v4-pro
priority: 60
kind: chat
# 静态请求头(比适配器默认优先)
headers: { X-Tenant: prod }
# 传给 Lua build_headers 的透传元数据
meta: { app_id: x, app_secret: y }
temperature: 0.7
max_tokens: 4096
timeout: 120s # 请求超时,默认 120s
```
产品名早已是 ModelRouter,二进制、配置目录、服务名与环境变量现已统一为
`modelrouter`:
| 旧 | 新 |
|---|---|
| `llmsproxy`(二进制) | `modelrouter` |
| `/etc/llmsproxy/` | `/etc/modelrouter/` |
| `llmsproxy.service` | `modelrouter.service` |
| `LLMSPROXY_*` | `MODELROUTER_*` |
### 自动探测模型列表
不想手抄模型 id 时,只填 URL 和 key,让网关去扫:
已部署的机器用迁移脚本升级(**默认 dry-run**,复制不移动,可回滚):
```bash
# 只探测,不写任何东西
curl -X POST http://127.0.0.1:8080/api/sources/discover \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…"}'
# → {"ok":true,"base_url":"…","endpoint":"","models":["gpt-x",…],
# "image_models":["gpt-image-2"],"models_path":"/models","latency_ms":1387,…}
# 探测后直接落库
curl -X POST http://127.0.0.1:8080/api/sources/discover/apply \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…","name":"vendor"}'
sudo ./packaging/migrate-to-modelrouter.sh # 预演
sudo ./packaging/migrate-to-modelrouter.sh --apply # 执行
sudo ./packaging/migrate-to-modelrouter.sh --rollback --apply
```
WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key 点一下,列表自动
填好,仍需点保存才生效。
几点值得知道:
- **URL 形态不用猜。** 厂商对「`/v1` 放 base 还是放 path」没有共识,探测会
依次尝试若干候选并返回答对的那个,同时给出归一化后的 `base_url` +
`endpoint`(`endpoint` 平时留空,因为 openai adapter 自带 `/chat/completions`)。
- **`image_endpoint` 是另一套规则。** 网关的图像默认路径是**绝对**的
`/v1/images/generations`,而 chat 默认是相对的。所以 `base_url` 带 `/v1` 时
必须显式给 `image_endpoint: /images/generations`,否则图像请求 404。
探测会替你填好。
- **图像模型只看上游自己声明的 modality**,绝不从模型名猜。`gpt-image-2`
这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持
图像生成的模型,其后每一层都被它拖垮。
- **不会逐模型探活。** 34 个模型的目录跑一轮要花钱且耗时数分钟,`/models`
是足够便宜的信号;模型是否真能应答留给正常请求去发现。
- **对已存在的源,必须显式 `replace_models: true`** 才会覆盖模型列表。重新
探测顺手抹掉为 AUTO 链手工调过的 priority,是最难排查的一类数据丢失。
- **`api_key` 留空表示沿用已存值**,不会把凭据清空。
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
运行文件生成在 `-config` 所在目录:`adapter_dir` / `runtime_file` 自动指向
配置文件旁边的 `adapters/` 与 `runtime.json`,从任意工作目录启动都可用。
运行时文件(`runtime_file`)中的敏感字段自动加密:
- 加密算法 AES-256-GCM,格式 `enc:v1:<base64>`。
- 主密钥来源:环境变量 `LLMS_PROXY_MASTER_KEY`(64 位 hex);否则读取
`runtime_file` 同目录的 `master.key`;都不存在时首次启动自动生成 `master.key`(0600)。
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
### 网关密钥(多密钥)
网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的
`keys` 字段(加密存储):
- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin
key,之后不再参与鉴权管理。
- **重要:首次启动后请在 WebUI「密钥」页更换管理员密钥**——初始密钥明文写在
`config.yaml` 里(每次安装随机生成),继续使用存在被盗风险;用新密钥登录后删除初始密钥。
- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或
`user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 +
重置周期)。
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
- 删除密钥即从运行时存储移除,立即失效。
#### 密钥用量配额
**配额按模型单独设置**:每个密钥的 `models[]` 里,每一条模型范围各自带一份
token 配额与请求数配额。一个模型用满只影响该模型,同一密钥的其它模型照常工作。
```yaml
keys:
- key: sk-gw-<hex>
role: user
name: agent-alice
models:
- model: deepseek-v4-flash
token_quota: 1000000 # 本周期内该 key 用这个模型的 token 预算
req_quota: 20000 # 本周期内的请求次数
period: nhour # "" | hour | week | month | nhour
hours: 6 # 仅 nhour
- model: AUTO
token_quota: 5000000 # AUTO 也是一条独立配额
period: day # ← 这种写法会被拒绝(词表只有 hour/week/month/nhour)
- model: kimi-k3 # 未列配额 = 无限
```
- **没有「整钥总配额」**:这是刻意的设计。整钥总额会让「把 A 模型的额度挪给
B 模型」变成一次全局重分配;按模型独立计费则每个模型各自可控,运维可以
看出哪个模型吃掉了预算。
- `period` 词表:空 = 永不过期(累计总量),`hour` / `week` / `month` = 固定窗口,
`nhour` + `hours` = 自定义小时数。**拼错的周期在写入时就被拒**,不会静默变成
永不过期。
- 配额严格按密钥隔离,且**同一密钥内按模型隔离**:A 密钥用满 `m1` 不会消耗
B 密钥的额度,同一密钥的 `m2` 也不受影响。
- 配额统计含聊天、流式、生图,跨重启从审计日志回放(保留 40 天,覆盖最长的
month 窗口)。
- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),消息里点名是哪个
模型用满了,客户端可等窗口重置后自动恢复;模型越权才是 403。
**admin 密钥永不受配额限制**(其 scope 上的配额也不强制),避免把管理员
锁在门外。
- 窗口用量按整点小时分桶统计,实际释放比配置窗口最多晚 1 小时(配额宁可晚释放
也不超发)。
- `PUT /api/keys/{key}` 提交 `models` 即同时提交它们的配额(配额就是 scope 的一部分,
不存在会与模型列表脱节的第二份预算)。显式 `0` = 解除该模型的限制。
##### 配额拒绝 vs 容量拒绝:两种「拒绝」含义不同
配额在**入口**检查,不占用任何上游槽位,因此是廉价拒绝;容量不足则要走
`busyWait` 有界等待后才返回 503。实测(容量 8、0.6s/请求、100 并发):
| 情况 | 状态码 | 延迟 | 是否打到上游 |
|---|---|---|---|
| 配额用尽(`req_quota` 50 → 100 并发) | `429 rate_limit_exceeded` + `Retry-After` | **19–21ms** | 否 |
| 容量打满(同一拓扑,未限配额) | `503 upstream_error` | ~2.6s(`busyWait`) | 超出部分否 |
客户端因此可以区分「等窗口重置」与「等上游腾出容量」:前者只需等待,后者
通常该降并发或换源。
##### 配额的开销
配额桶按 (密钥, 模型, 整点小时) 分桶保留 40 天,实测(AMD 7840HS):
- 窗口查询按窗口长度而非保留总量扫描:1h 窗口 49ns、24h 窗口 55ns、
30d 窗口 3.9μs(此前全扫保留总量,960 桶时 5.9μs)。
- 记录一条请求:283ns、3 分配(与引入配额前相同,分配来自 ring buffer)。
- 内存:生产形态(7 密钥 × 8 模型 × 2 源 × 满 40 天 retention)约 **3.7MB**。
按源 pin 的 `source::model` 桶**惰性创建**——只有当某条配额真的 pin 了
某个源时才维护,否则每条记录多写一份桶,在 20 密钥 × 8 模型 × 3 源下会
多占 18MB。
代价:配置 pinned 配额之前的历史用量无法事后按源拆分,所以 **pinned 配额的
首个窗口可能少算**。
```bash
# 配额耗尽时客户端看到(点名了具体模型)
HTTP/1.1 429 Too Many Requests
Retry-After: 2100
{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}}
```
### 模型路由
`/v1/chat/completions` 的 `model` 解析顺序:
1. `source/model` 或 `source:model` 前缀 → 指定源;
2. 精确匹配某个源的 `model`;
3. 配置 `default_model: AUTO` 时 → 走优先级页保存的 AUTO 链(见下);
4. 否则回落(返回错误)。
任何 OpenAI 客户端,只要 `model` 设为某个源的 `name/任意名`,即可锁定走该源;
设为 `AUTO`(或网关配了 `default_model: AUTO`)即自动按优先级链选源。
### AUTO 链(优先级页)
AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models[].priority` 数字不再
参与调度、也不再显示。**聊天与生图是两套独立的链**,在优先级页面通过「聊天 / 生图」
按钮切换编辑:
- **聊天链**(`runtime_file` 的 `auto` 字段):服务 `POST /v1/chat/completions`
的 `model: AUTO` 请求;只接受 `kind: chat` 模型,配置在链里的生图槽位会被忽略。
- **生图链**(`runtime_file` 的 `auto_image` 字段):服务 `POST /v1/images/generations`
的 `model: AUTO` 请求;只接受 `kind: image` 模型。未配置生图链时,AUTO 生图回落
到「所有含生图模型的源、按注册顺序」的旧行为,兼容旧配置。
每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
- `tier` 表示优先级档位:同一档的模型并排、共享该优先级;档位从上到下递减。
- 同一模型可配置多个槽位(如 A 源低配 → B 源低配 → A 源高配 → B 源高配),按档位顺延。
- `token_quota` > 0 时该槽位在重置周期内用满即顺延到下一槽位;`period` 支持
`hour` / `week` / `month` / `nhour`(配合 `hours`),空 = 不限。
- 同 tier 内按「偏好分」排序,失败槽位冷却后自动跳过;冷却 / 配额耗尽 / hard
错误会顺延到下一 tier,全部失败时返回 503 并附上每档失败摘要。
### 冷却与半冷却探测(自愈调度)
槽位失败后进入指数退避冷却(5s → 10s → 20s …,上限 **5 分钟**)。冷却不是全时段
硬闸:
- **前半段完全静默**:刚失败的槽位不接任何流量,避免对故障上游持续施压。
- **后半段放行 1 个探测**:窗口过半后,同一 `(源, 模型)` 最多允许 **一个** 请求作为
探测通过。探测槽数 = `clamp(max_concurrent / 10, 1, 2)`——`max_concurrent: 10`
的源恰好放行 1 个探测请求。
- **探测排在最后**:同 tier 内探测候选永远排在健康候选之后,只有没有正常槽可用时
才承接真实流量,因此探测不会抢走可用容量。
- **探测即真实请求**:成功则立即清零失败计数与冷却,槽位当场回到正常轮询——
上游恢复/额度恢复后不必等满整个冷却窗口。失败则重开一个新窗口,下次探测顺延到
新窗口的中点,不会连续重试。
分类冷却策略:
| 情况 | 冷却 | 说明 |
|---|---|---|
| 传输错误 / 5xx | 5s→10s→20s…,上限 5min | 指数退避 |
| 401 / 403 凭据错误 | 10min | 换 key 后由下一次探测自动接回 |
| 429 限流 | 30s 固定 | 「太快」不等于「坏了」,不进指数阶梯 |
| 配额耗尽(`insufficient_quota` / `余额不足` 等) | 对齐配额窗口,上限 30min | **不进指数阶梯、不涨失败计数**,额度恢复即可用 |
WebUI「优先级」页的槽位健康标签会区分 `冷却` / `待探测` / `探测中`,悬停可看窗口
起点、探测放行时刻与完全恢复时刻。
### 源(Source)与适配器(Adapter)的关系
- **源** 是到某个上游的连接描述:`name`、`base_url`、`api_key`、模型列表与优先级。
- **适配器** 是协议转换逻辑(Lua 脚本):把统一 OpenAI 格式请求转成上游原生格式,
再把上游响应/流式分块转回统一格式。
- 一个适配器可被多个源复用(如 `openai.lua` 同时服务多个 OpenAI 兼容站点);
同一个源也可以切换不同适配器(改 `adapter` 字段即可)。
- 源决定“连谁、暴露哪些模型”,适配器决定“怎么对话”——二者在 `sources[]` 条目中
通过 `adapter` 字段关联。
加载流程(`internal/core` 的 `Core` 负责装配):
1. 启动时 `lua.NewVM(adapter_dir)` 加载全部适配器:内置适配器(编译期 embed)+
`adapter_dir` 下的同名覆盖文件;内置文件写在代码内,覆盖文件要求更高优先级。
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
运行时源,二者按名称合并成完整源列表。
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链"与"密钥模型范围"
都即时生效(写入运行时文件或 `adapter_dir` 后重新装配,无需重启);直接编辑
`config.yaml` / `adapter_dir` 下的文件则需要重启进程才会重新加载。
在 WebUI 删除源/适配器时:运行时(WebUI 创建)的源与 `.lua` 文件会真正移除;
`config.yaml` 中定义的基源无法改写配置文件,采用删除标记隐藏(重启后仍隐藏),
在 UI 里重新添加同名源即可恢复。
### 源模板(多 key 负载均衡)
多个上游 key 共享同一套配置(URL / 适配器 / 模型列表 / 并发 / rpm)时,逐一复制
整段 source 太冗余。**源模板**保存除 `name` 和 `api_key` 以外的全部源字段,
从模板一键创建多个仅 key 不同的源,天然支持多 key 负载均衡(每个展开源独立调度、
独立冷却、独立健康状态)。
- WebUI「源」页右上角「**模板管理**」按钮:列出全部模板,可编辑 / 删除 / 新建。
- 添加源弹窗右上角两个按钮:
- **从模板创建** → 选模板 → 表单自动填充(name + key 仍需你填);
- **存为模板** → 把当前表单非 key/name 字段存为新模板。
- 模板持久化在 `runtime_file` 的 `source_templates` 字段,与运行时源一样热管理,
改完即时生效。模板本身不含密钥,可以安全共享 / 纳管。
模板不会自己变成源——它只是「配方」。真正承担流量的是从模板创建出来的、带 key
的具体源。
### 内存占用(实测与调优)
内存不是一个定数:它随**配置的源数量**增长(每个源一个 `http.Transport` 与连接池、
一组健康状态),与运行时长无关。本机实测(Linux x86_64,12 核):
| 部署形态 | 启动 RSS | 稳态 RSS |
|---|---|---|
| 1 源 / 1 适配器(最小配置) | ~4 MB | ~10 MB |
| 1 源 + 29 MB 历史审计日志 | ~19 MB | ~20 MB |
| **16 源 / 13 适配器 / 59 模型(本机生产)** | ~28 MB | **~32–35 MB** |
> 历史参考:本项优化前同一生产配置为 **~105 MB**。降幅来自三处:审计日志的**原始记录**
> 不再常驻内存(约 25 MB;统计聚合仍扫全量,但扫完即释放)、Lua 状态池不再单调增长、
> 以及下面两个运行时开关。
内存构成(生产实例分段测量,`/proc/<pid>/smaps`):
| 区域 | RSS | 说明 |
|---|---|---|
| Go 堆 | ~14 MB | provider/registry/scheduler 结构 + 连接池缓冲 |
| 其他匹名(线程栈 / LuaJIT chunk / runtime) | ~12 MB | 与线程数、已加载适配器数相关 |
| 二进制 text+rodata | ~8 MB | 映射的可执行文件页(只读、可被内核回收) |
| Go runtime 预留 | ~4 MB | VSZ 上看到的 2 GB+ 是地址空间预留,不占物理内存 |
| 共享库 | ~3 MB | libc / libluajit / libm |
**两个推荐的部署开关**(只适用于环境变量,无需改代码):
```ini
# /etc/systemd/system/llmsproxy.service
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
```
- `MALLOC_ARENA_MAX=2`:LuaJIT 的分配走 cgo → glibc malloc,glibc 默认允许 `8×nproc`
个 per-thread arena,每个碰到 malloc 的 OS 线程会占用一个(各约 1 MB,且**不归还给系统**)。
实测从 8–15 个 arena(约 7–12 MB)降到 **0**。
- `GOGC=50`:把 Go 堆增长目标减半。**单独使用无效**(省下的堆会立即被更多 glibc
arena 吃掉,实测 20.3 → 21.5 MB 反而变大),必须与 `MALLOC_ARENA_MAX` 配合,
两者同时开启才降 **~19%**。网关是 I/O 密集型(本机 9 小时仅消耗 1min10s CPU),
多出的 GC 周期与它的空闲 CPU 相比可忽略。
> 桌面版(Electron)已在 `cmd/gui/main.js` 里默认为内嵌核心注入这两个开关;手动 export 同名
> 环境变量可覆盖。如果你用自己的 systemd unit / 容器,建议照上面加上。
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
温和场景不必开。
### thinking 预算
请求体带 `"thinking": {"enabled": false}` 时关闭推理;`"thinking": {"enabled": true,
"budget_tokens": 4096}` 指定预算。内核只定义这个**中立**语义字段,映射到具体协议由
适配器负责(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig.thinkingBudget`、
DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disable_thinking` /
`extra_body` 这类私有字段。
### WebUI
内置管理界面(`GET /`),登录后可在浏览器完成:
- **状态页**:源在线状态(实时探测 + 悬停看错误)、模型/源/key 用量统计、请求记录,
支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。
- **对话页**:流式/非流式调试。
- **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期),
管理员管理全部 key,用户只看到自己的 key。每个模型砖块显示自己的配额徽标
(如 `1.0K 77×·1h`,未设配额显示 `∞`),点开可编辑该模型的 token 配额、
请求数配额与重置周期 —— 配额按模型独立生效,一个用满不影响同一 key 的其它模型。
「我的密钥」页对用户展示本 key 的模型范围与各自配额。
- **优先级页**:拖拽积木配置 AUTO 链档位。
- **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。
- **适配器页**:上传 / 删除 Lua 适配器脚本。
改动写入 `runtime_file`(重启仍生效)。
## Lua 适配器协议
完整 API 见 **[Lua 适配器 API 文档](docs/lua-adapters.md)**([English](docs/lua-adapters-en.md))。
每个适配器是一个返回 table 的 Lua 脚本(`internal/lua/adapters/<name>.lua`),
加载时可被 `adapter_dir` 下的同名脚本覆盖——**改 Lua 脚本无需重编译**,
重启即生效;通过 WebUI 上传的适配器与在线编辑的源配置则即时生效。
```lua
return {
name = "mysrc",
version = "1.0.0",
endpoint = "/chat/completions", -- 上游路径(可被 source.endpoint 覆盖)
headers = { ["X-Static"] = "v" }, -- 静态默认请求头(build_headers 缺省时使用)
-- 请求转换:把 OpenAI 格式 req 转成上游原生格式,返回字符串
transform_request = function(raw_json) ... end,
-- 响应转换:把上游原生响应转成统一格式字符串
-- { content, reasoning_content, finish_reason, token_usage{...}, tool_calls[{...}] }
transform_response = function(raw_json) ... end,
-- 流式分块转换:把上游 SSE data 转成 { content, done, ... },返回 "" 则跳过
transform_stream_chunk = function(raw_chunk) ... end,
-- [可选] 动态请求头/签名钩子
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
-- [可选] 出向序列化(契约 v2):上游不是 OpenAI 协议时接管写给客户端的字节。
-- 不声明则沿用内核的 OpenAI 兜底;声明即完全取代,内核不再混合字段。
serialize_response = function(result) return body_str end,
serialize_open_preamble = function(result) return bytes end,
serialize_chunk = function(delta) return bytes end,
serialize_close = function(result) return bytes end,
}
```
内置信号辅助:`hmac_sha256_hex(key, data)`、`sha256_hex(data)`、`base64_encode(s)`、
`tohex(s)`、`json.encode/decode`、`log(level, msg)`。
### 内置适配器
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `kimicode`
`qwen38` `sensenova` `trae`。
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);
推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为
`thinking.type = "disabled"`。
**kimicode** 是展示 `build_headers` 的样例:云端校验调用方 app,需要按
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。
## 桌面 GUI(Electron)
可选独立产物,面向内网桌面端用户:**内嵌 ModelRouter 核心**(Clash Verge 形态),
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 WebUI
(状态/对话/密钥/优先级/源/适配器 六个页面,免登录)。服务器用户继续使用纯 Go 二进制。
### 两种安装包:Headless 与 Desktop
发布到 Release 的安装包分为两类,面向不同用户:
- **Headless(服务器版)**:纯核心二进制(无 Electron),配置驱动,适合服务器、
容器、systemd 等无人值守场景。发布包含 `deb` + `rpm`(含 systemd unit、内存调优
与示例配置)与 `tar.gz`(二进制 + `config.example.yaml` + `adapters/` + README),
Windows 为 `.zip`。
- **Desktop(桌面版)**:Electron GUI 安装包,内嵌核心,适合个人桌面日常使用。
### 选择 GUI 还是纯后端
| 场景 | 推荐 | 理由 |
| ------ | ------ | ------ |
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(二进制 ~8MB;RSS 随源数量,单源 ~10MB、16 源 ~32–35MB,见[内存占用](#内存占用实测与调优)),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
| 个人桌面日常使用 / 多设备内网共享 | **Desktop**(GUI) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
| Windows 桌面 | **Desktop** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
| CI 一键出三平台安装包 | **Desktop 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
两者完全同源:GUI 内嵌的就是纯后端的同一份 `llmsproxy` 核心(LuaJIT 版),
配置/适配器格式完全一致,可随时互换。
### 构建与运行
```bash
cd cmd/gui
npm install
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy)
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
make core-dist # 构建核心 deb + rpm + tar.gz(服务器版,需 nfpm)
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
```
产物在 `cmd/build/gui-dist/`:`ModelRouter-<ver>.AppImage`、
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win);
核心包在 `cmd/build/dist/`:`llmsproxy_<ver>_amd64.deb`、`llmsproxy-<ver>.x86_64.rpm`。
rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` 产物体检
(大小下限门禁——1.3.0 曾因宿主 wine 损坏产出 264KB 的残缺 exe 却没有被拦住)。
发布时会把产物重命名为 `ModelRouter-Desktop-*`(GUI)与 `ModelRouter-Headless-*`(纯后端)
两类上传到 GitCode Release。
#### Windows 打包(docker 固化方案,推荐)
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包**全在 docker 内**,宿主机
**无需 mingw、无需 wine、无需 node**。镜像内置 wine(含 i386 运行时),修复了
此前宿主 wine 损坏(缺 syswow64 → `c0000135`)导致产出 264KB 残缺 exe 的问题。
- 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 +
node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。
LuaJIT 源码取自 gitcode 镜像(国内可达),github 兜底;Go 依赖走 `https://goproxy.cn`。
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder
NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder
的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。
- 首次运行自动构建 `modelrouter/win-builder` 镜像(约 5–10 分钟);已存在则直接复用。
- 打包完成后自动跑 `packaging/verify-dist.sh` 门禁:exe 低于 5MB / deb·rpm·7z 低于
10MB 即失败——残缺安装包不可能再溜进 Release。
```bash
# 全 Windows 发布(核心重编 + NSIS):
make gui-win-docker
```
### 特性
- **内嵌核心**:自动拉起捆绑的 `llmsproxy`(LuaJIT 版),配置/密钥/adapter 存于
用户数据目录 `<userData>/profile/`,首次运行自动生成随机 admin key 写入 `keys`
(非 seed)并自注入,WebUI 免登录、不会提示"更换初始密钥"。端口可在设置中更改(默认 8787)。
- **系统托盘**:内核状态点、开机自启/静默启动开关、启停/重启内核、退出。
- **静默启动**:`--silent` 参数或设置项,启动仅驻托盘不弹窗;首次运行始终显示窗口。
- **开机自启**:Windows/macOS 用 `setLoginItemSettings`;Linux 写
`~/.config/autostart/modelrouter-gui.desktop`,静默开启时自动附加 `--silent`。
## 目录
```
cmd/llmsproxy # 入口(纯 Go 服务端)
cmd/gui # Electron 桌面 GUI(内嵌核心 + 托盘 + 打包)
internal/config # YAML 配置加载/校验
internal/lua # LuaJIT VM + worker 池 + AdapterCache + 内置适配器 (embed)
旧路径与旧环境变量在读取侧仍被接受,因此升级不会因为顺序问题立刻失效。
`deploy.sh` 在检测到旧 `/etc/llmsproxy` 而新配置不存在时会**中止**,避免部署出
一个「能启动但没有 key / 没有源」的空实例。
插件 SDK 同名调整:唯一公开入口是 `modelrouter`(`llmsproxy` 仅作兼容别名),
详见 [docs/plugins.md](docs/plugins.md)。
## 目录结构
```text
cmd/modelrouter # 入口(纯 Go 服务端)
cmd/gui # Electron 桌面壳(内嵌同一份核心)
internal/config # 配置解析、加密存储、运行时文件
internal/core # 调度、内核动作、状态与审计
internal/lua # LuaJIT VM + 弹性 worker 池 + 内置适配器 (embed)
internal/lua/plugins # 可选插件示例(不随二进制/发行包分发)
internal/provider # Provider(HTTP) + Registry(路由)
internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + SDK/流式 + WebUI
internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + 流式 + WebUI
internal/types # 统一格式 & OpenAI wire 类型
packaging/ # deb/rpm/tar 打包、systemd unit、迁移脚本
docs/ # 主题文档
```
## 开发协作
- **Git 分支工作流**:主分支 / 特性分支 / 发布分支的规范见
**[docs/git-workflow.md](docs/git-workflow.md)**([English](docs/git-workflow-en.md))。
主线:`main` 永可部署 → 特性合入 `main` → 切 `release/vX.Y.Z` 打 tag 发布 →
hotfix 提交发布分支并 cherry-pick 回 `main`。
- **Lua 适配器协议**:见 **[docs/lua-adapters.md](docs/lua-adapters.md)**
([English](docs/lua-adapters-en.md))。
## 测试
## 开发与测试
```bash
go test -tags luajit ./...
go test -tags luajit ./... # 全量
go test -tags luajit ./internal/lua # 单包(LuaJIT 相关改动必跑)
```
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
模型路由、多模态透传与 thinking 预算。
- 分支与发布纪律见 [docs/git-workflow.md](docs/git-workflow.md)。
- 插件放置在 `plugin_dir`,网关启动时**不会**创建、覆盖或删除任何插件文件;
升级桌面端也不会动它们。

View File

@ -1,626 +1,151 @@
# ModelRouter
> **Repository**: <https://atomgit.com/HeNanMU-EEA/ModelRouter>
>
> **中文**: [README.md](./README.md)
>
> **AI-assisted**: This project is developed with AI-assisted coding (code and docs are produced in collaboration with AI, reviewed by humans).
> **AI-assisted**: code and documentation were produced with AI assistance and
> reviewed by a human.
A lightweight, unified OpenAI-compatible LLM gateway for internal networks —
configure once, and let multiple services (agents, SDKs, bots) share many
upstreams (DeepSeek, Qijiar, OpenAI, Anthropic, Gemini, Groq, Mistral, Ollama,
KimiCode…) behind a single endpoint. Pick a specific model or use **AUTO** mode,
which routes to the best healthy upstream by configured priority.
An intranet gateway for people with several LLM subscriptions: configure once and
let multiple services share the upstream capacity. Pick a model explicitly, or let
`AUTO` choose by priority among the sources that are currently healthy.
> **Lightweight, no recompile to add sources**: the gateway is a single Go
> binary (8-12 MB, ~8 MB stripped; zero runtime dependencies). Adding or switching an upstream is
> just a `sources` entry in `config.yaml` (or via the WebUI) or a `.lua`
> adapter — **no Go changes, no recompile**. Changes submitted through the
> WebUI take effect immediately (hot reload); editing `config.yaml` or a `.lua`
> file under `adapter_dir` by hand requires a process restart.
The gateway itself is a single Go binary (~10 MB, no runtime dependencies). Adding
or switching an upstream is a `sources` entry in `config.yaml` (or the WebUI), or a
dropped-in `.lua` adapter — **no Go changes, no rebuild**. Changes submitted through
the WebUI take effect immediately; editing `config.yaml` or a `.lua` adapter file
directly requires a restart.
## Overview
## What it does
Exposes a standard `OpenAI Chat Completions` API (`/v1/chat/completions` +
`/v1/models`) to your internal network, translating between multiple upstream
protocols via **Lua adapters**. Supports one-shot calls, SSE streaming,
**AUTO model routing**, multimodal passthrough, and tool calls.
Extracted and independently evolved from the multi-source LLM adapter layer of
[HomeAgent](https://gitcode.com/JianFeeeee/HomeAgent)
(`internal/agent/api/provider.go` + `internal/lua/adapters/*`).
## Features
- **Multi-source**: any number of upstream sources in one process, routed by the
request's `model`.
- **AUTO mode**: with `default_model: AUTO`, scheduling follows the tiered AUTO
chain saved on the Priority page (see below).
- **Unified output**: every source speaks OpenAI format (incl.
`reasoning_content`, `tool_calls`, `usage`).
- **Image generation**: `POST /v1/images/generations`, routed to models with
`kind: image`.
- **Per-model quota**: each key gives every model its own token and request caps
plus a reset period (hour/week/month/custom N hours). One model running out
affects only that model — the key's other models keep working. Exhaustion
answers 429 + `Retry-After` naming the model, so a client resumes when the
window rolls over; admin keys are never capped.
- **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly;
Anthropic/Gemini/Ollama are translated automatically.
- **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker
pool for safe concurrency. Pools are **elastic**: the sum of `max_concurrent`
over an adapter's sources is a ceiling, not a preallocation — states are booted
on demand and reclaimed when demand drops, so an idle gateway holds close to
zero Lua states. The grow step follows the adapter's max concurrency
(`clamp(ceil(max/8), 1, 8)`, and never exceeds the number of queued callers);
the shrink step follows the live connection count
(`clamp(ceil(slack/(1+in_use)), 1, slack)`), so an idle pool collapses in one
round while a busy one gives up a single state at a time.
- **On-demand request logs**: dashboard totals are computed from the **full**
audit history (streamed once at startup and released — 29 MB / 221k lines in
~260 ms), while the raw records are never held in memory: the dashboard loads
one screen, scrolling pages the rest straight off disk, CSV export streams in
O(1) memory, and leaving the page releases everything.
- **Self-healing cooldown**: cooldown is capped at 5 minutes and, past the
window's midpoint, exactly one probe request is allowed through; a recovered
upstream (or a reset quota) returns to full rotation on that probe instead of
waiting out the window.
- **disable_thinking**: `disable_thinking: true` toggles reasoning per-request.
- **Lua adapter protocol**: each source mounts a `.lua` adapter with
`transform_request` / `transform_response` / `transform_stream_chunk` — all
protocol differences live in Lua, Go only schedules and proxies.
- **Signature / header hooks**: adapters may define `build_headers(meta)` to
inject/sign request headers before the HTTP call (e.g. KimiCode style
app-validation), with helpers like `hmac_sha256_hex`, `sha256_hex`,
`base64_encode`.
- **WebUI**: built-in management page to view/add/edit sources & models, edit
the AUTO chain, and manage per-key model scopes, persisted to a runtime file.
- **Encrypted secrets at rest**: upstream `api_key`, custom header values and
gateway keys are stored AES-256-GCM encrypted (`master.key` 0600 next to the
runtime file, or `LLMS_PROXY_MASTER_KEY`).
- **Live source probing**: the status page probes each source
(`GET {base}/models`, falling back to a minimal chat call) without touching
the scheduler's backoff state; errors are shown on hover.
- **Auth**: the gateway validates client keys via `gateway_keys`; independent
from each upstream's own key.
- **Streaming**: SSE `chat.completion.chunk` with a role-first chunk and
`[DONE]` terminator.
- **One OpenAI-compatible API**: `/v1/chat/completions`, `/v1/models`,
`/v1/images/generations`. Every source speaks the same shape outwards, including
`reasoning_content`, `tool_calls` and `usage`.
- **Many sources, one AUTO chain**: configure any number of upstreams; `AUTO`
follows the priority tiers saved in the WebUI, fails over between them, cools
down broken sources and probes them back to health at half-cool.
- **Protocol differences live in Lua**: each source binds a `.lua` adapter that
transforms requests, responses and stream chunks both ways, and can sign headers
via `build_headers(meta)`. Vendor knowledge stays out of the Go kernel.
- **Plugins are independent**: business extensions (channel login, billing, quota
probing) are external Lua plugins loaded from `plugin_dir`. They are not compiled
into the binary and not shipped in release archives, and they talk to the kernel
only through the `modelrouter` SDK.
- **Multi-tenant keys**: gateway keys are separate from upstream keys, and can be
scoped to a model set, an AUTO chain and a quota.
- **Encrypted at rest**: upstream keys, custom header values and gateway keys are
sealed with AES-256-GCM; `master.key` is 0600, or injected via
`MODELROUTER_MASTER_KEY`.
- **Observability**: live source probing, HTTP access log, config-change audit,
request and usage CSV export. Writes are kept off the request hot path.
## Quick start
```bash
GOMODCACHE=... GOPROXY=off go build -tags luajit -o llmsproxy ./cmd/llmsproxy
./llmsproxy -config config.yaml # first run generates a default config and prints a random admin key
# LuaJIT development libraries are required; -tags luajit is mandatory
GOMODCACHE=... GOPROXY=off go build -tags luajit -o modelrouter ./cmd/modelrouter
# First run generates a default config and prints a random admin key
./modelrouter -config config.yaml
```
> Depends on [golua](https://github.com/aarzilli/golua) (LuaJIT bindings).
> You **must** build with `-tags luajit`; otherwise the built-in superset
> gopher-lua path is used (behavior differs slightly).
> Depends on [golua](https://github.com/aarzilli/golua) (LuaJIT bindings). Without
> `-tags luajit` the build falls back to gopher-lua, which behaves slightly
> differently.
> **No config is shipped in the repo** (config files carry real keys). On
> first run the binary generates a default config at the `-config` path: a
> random admin key (printed to the startup log), loopback-only `127.0.0.1:8080`,
> and a keyless source ready to chat. Rotate the admin key in the
> WebUI after first login.
> **No config in the repo**: the repository ships no config file (they contain
> keys). The generated default binds `127.0.0.1:8080` only; rotate the admin key in
> the WebUI after the first login.
```bash
# no key -> 401
# No key -> 401
curl http://127.0.0.1:8080/v1/models
# one-shot ($KEY = the admin key printed at first startup)
# Non-streaming ($KEY = the admin key printed at first start)
curl -H "Authorization: Bearer $KEY" \
-d '{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}' \
http://127.0.0.1:8080/v1/chat/completions
# streaming
# Streaming
curl -N -H "Authorization: Bearer $KEY" \
-d '{"model":"AUTO","stream":true,"messages":[{"role":"user","content":"hi"}]}' \
http://127.0.0.1:8080/v1/chat/completions
```
Any OpenAI SDK works: point `base_url` at the gateway, use any authorized
gateway key as the API key.
## Configuration
Generated on first run at the `-config` path (default `./config.yaml`). Core fields:
```yaml
listen: 127.0.0.1:8080 # bind address (keep internal/loopback)
gateway_keys: [sk-gw-<random>] # initial admin key seed (random per install), migrated to the store on first start
default_model: AUTO # when model is unroutable, follow the AUTO chain
adapter_dir: adapters # Lua adapter dir; created+seeded if missing, read-only otherwise
runtime_file: runtime.json # WebUI-edited sources/keys/AUTO chain persist here
sources:
- name: deepseek
base_url: https://api.deepseek.com
api_key: sk-... # or api_key_env: SOME_ENV to read from an env var
adapter: deepseek
max_concurrent: 8
models:
- id: deepseek-v4-flash
priority: 100 # YAML sources seed the AUTO chain on first start
kind: chat
- id: deepseek-v4-pro
priority: 60
kind: chat
# static headers (take precedence over adapter defaults)
headers: { X-Tenant: prod }
# passthrough metadata for the Lua build_headers hook
meta: { app_id: x, app_secret: y }
temperature: 0.7
max_tokens: 4096
timeout: 120s # request timeout, default 120s
```
Sensitive fields in the runtime file (`runtime_file`) are encrypted at rest:
- AES-256-GCM, stored as `enc:v1:<base64>`.
- Master key source: env var `LLMS_PROXY_MASTER_KEY` (64 hex chars), else a
`master.key` file next to the runtime file; if neither exists it is generated
on first start (mode 0600).
- Old plaintext files still load; the first UI save migrates the whole file to
ciphertext.
- Back up `master.key` with your config — losing it makes the secrets
undecryptable. Do not commit it.
### Gateway keys (multi-key)
Gateway auth uses a "multi-key + role + model scope" model. Keys are persisted
under the `keys` field of the runtime file (encrypted at rest):
- The `gateway_keys` config is only an **initial admin key seed** — it is
migrated into the runtime store on first start and no longer drives auth.
- **Important: after first start, replace the admin key via the WebUI Keys
page.** The seed key is written in plaintext in `config.yaml`, so keeping it
active is a security risk; create a new admin key, log in with it, then
delete the seed key.
- The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin`
manages everything, `user` sees only its own key) and an optional **model
scope** (model + source + token quota + reset period). Each model brick shows
its own budget badge (e.g. `1.0K 77×·1h`, `∞` when uncapped); clicking it
edits that model's token quota, request quota and reset period. Quotas apply
per model, so one model running out never blocks the key's others. The
"My key" view shows a user its model scopes and their budgets.
- Clients authenticate with any authorized key's plaintext as
`Authorization: Bearer <key>`.
- Deleting a key removes it from the store immediately.
#### Per-key usage quota
**Quotas are per model.** Each key's `models[]` list gives every model its own
token budget and request budget. One model running out affects only that model
— the key's other models keep working.
```yaml
keys:
- key: sk-gw-<hex>
role: user
name: agent-alice
models:
- model: deepseek-v4-flash
token_quota: 1000000 # this key's token budget for this model
req_quota: 20000 # requests within the window
period: nhour # "" | hour | week | month | nhour
hours: 6 # n-hour only
- model: AUTO
token_quota: 5000000 # AUTO is a quota entry like any other
period: hour
- model: kimi-k3 # no quota listed = unlimited
```
- **There is deliberately no key-wide total.** A key-wide cap would make
"move A's budget to B" a global reallocation; per-model budgets keep each
model independently controllable, so it stays visible which model is
actually consuming the spend.
- `period`: empty = never resets (lifetime total); `hour` / `week` / `month` =
fixed windows; `nhour` + `hours` = a custom hour count. **A misspelled
period is rejected at write time** rather than silently becoming a
never-resetting quota.
- Quotas are isolated per key *and* per model within a key: one key exhausting
`m1` never draws on another key's budget, and never blocks the same key's
`m2`.
- Usage counts chat, streaming and image requests, and survives a restart by
replaying the audit log (40 days retained, covering the longest `month`
window).
- An exhausted quota returns **429 + `Retry-After`**
(`rate_limit_exceeded`) and the message names the model that ran out, so a
client resumes when the window rolls over; a model the key may not use stays
403. **Admin keys are never capped** (quotas on their scopes are not
enforced either), so a cap can never lock the operator out.
- Buckets are whole unix hours, so a window frees up at most an hour late
(deliberately freeing late rather than overspending).
- `PUT /api/keys/{key}` submits quotas by submitting `models` — the caps are
part of the scope, so there is no second budget that can drift out of sync
with the model list. An explicit `0` lifts that model's cap.
##### Quota rejection vs capacity rejection
A quota is checked at the entry point and occupies no upstream slot, so it is a
cheap rejection. Running out of capacity instead waits out the bounded
`busyWait` before answering 503. Measured (capacity 8, 0.6 s/request, 100
concurrent clients):
| Case | Status | Latency | Reaches upstream |
|---|---|---|---|
| Quota spent (`req_quota` 50, 100 concurrent) | `429 rate_limit_exceeded` + `Retry-After` | **19–21 ms** | no |
| Capacity exhausted (same topology, no quota) | `503 upstream_error` | ~2.6 s (`busyWait`) | overflow: no |
So a client can tell "wait for the window to roll over" apart from "wait for
upstream capacity" — the first only needs patience, the second usually means
back off concurrency or switch sources.
##### What the quota costs
Buckets are kept per (key, model, whole unix hour) for 40 days. Measured on an
AMD 7840HS:
- A window query scans the window, not the whole retention: 49 ns for 1 h,
55 ns for 24 h, 3.9 µs for 30 d.
- Recording one request: 283 ns, 3 allocations (unchanged from before this
feature; the allocations come from the record ring buffer).
- Memory: the production shape (7 keys x 8 models x 2 sources at full 40-day
retention) costs about **3.7 MB**. The `source::model` bucket used by a
source-pinned quota is created **lazily** — it is maintained only once some
quota actually pins that source, because writing it on every record would
cost an extra 18 MB at 20 keys x 8 models x 3 sources.
The trade-off: usage recorded before a pinned quota was configured cannot be
split by source afterwards, so **a pinned quota may under-count its first
window**.
```
HTTP/1.1 429 Too Many Requests
Retry-After: 2100
{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}}
```
### Model routing
`/v1/chat/completions` `model` resolution order:
1. `source/model` or `source:model` prefix → pinned source;
2. exact match of a source's `model`;
3. with `default_model: AUTO` → follow the AUTO chain from the Priority page
(see below);
4. otherwise the request errors.
Any OpenAI client can pin to a source by setting `model` to its
`name/anything`; `AUTO` (or the gateway's `default_model: AUTO`) follows the
tiered AUTO chain.
### AUTO chain (Priority page)
AUTO scheduling is driven **only** by the rules saved on the Priority page. The
numeric `models[].priority` in source configs no longer participates in
scheduling and is no longer shown. **Chat and image generation are two
independent chains**, switched in the Priority page via the `Chat / Image` toggle:
- **Chat chain** (`auto` field): serves `POST /v1/chat/completions` with
`model: AUTO`; only `kind: chat` models are honored (image slots are skipped).
- **Image chain** (`auto_image` field): serves `POST /v1/images/generations` with
`model: AUTO`; only `kind: image` models are honored. With no image chain
configured, AUTO image generation falls back to legacy discovery (all sources
exposing an image model, in registry order) for backward compatibility.
Each rule is one "slot": `{ model, source, tier, token_quota, period, hours }`.
- `tier` is the priority tier: models in the same tier sit side by side and
share it; tiers run high → low.
- The same model may appear in several slots (e.g. A low → B low → A high →
B high) and is tried in tier order.
- `token_quota` > 0 means the slot is skipped once its tokens within the reset
window are exhausted; `period` supports `hour` / `week` / `month` / `nhour`
(with `hours`); empty = unlimited.
- Within a tier, slots run by preference score; cooling / quota-exhausted /
hard-failed slots fall through to the next tier, and total failure returns
503 with a per-tier summary.
### Sources vs. adapters
- A **source** describes a connection to an upstream: `name`, `base_url`,
`api_key`, model list and priorities.
- An **adapter** is the protocol translation logic (Lua script): converts the
unified OpenAI request to the upstream's native format and back.
- One adapter serves many sources (e.g. `openai.lua` for any OpenAI-compatible
site); one source can switch adapters via its `adapter` field.
- Sources decide *whom to talk to and which models to expose*; adapters decide
*how to talk*. They are linked by the `adapter` field inside each `sources[]`
entry.
Loading flow (assembled by `Core` in `internal/core`):
1. On startup `lua.NewVM(adapter_dir)` loads all adapters: built-in ones
(embedded at compile time) + same-name override files under `adapter_dir`.
2. `config.Load` reads `config.yaml`; `config.NewStore(runtime_file)` reads
WebUI-edited runtime sources; both are merged by name.
3. `rebuildRegistry` creates a `provider.Provider` per source (holding its
adapter), sizing the adapter's worker pool from the source's concurrency
limit.
4. Incoming requests are routed by `Registry` to a provider, which calls the
adapter's `transform_request` → HTTP call → `transform_response` /
`transform_stream_chunk`.
"Add/Edit source", "Upload adapter", "AUTO chain edits" and "per-key model
scopes" in the WebUI take effect immediately (written to the runtime file /
`adapter_dir`, then reassembled — no restart). Directly editing `config.yaml`
or files under `adapter_dir` requires a process restart.
Deleting a source/adapter in the WebUI truly removes runtime (UI-created)
sources and `.lua` files; base sources defined in `config.yaml` cannot rewrite
that file, so they are hidden via a deletion tombstone (still hidden after
restart) and can be restored by re-adding the same name in the UI.
### Source templates (multi-key balancing)
When several upstream keys share one config (URL / adapter / model list /
concurrency / rpm), copying the whole source block N times is wasteful. A
**source template** stores every source field except `name` and `api_key`, so you
create N key-only-different sources from one shared skeleton — natural multi-key
load balancing (each expanded source schedules, cools down, and reports health
independently).
- WebUI Sources page, top-right **Templates** button: list all templates with
edit / delete / create.
- Add-source dialog has two header buttons:
- **From template** → pick a template → form auto-fills (name + key still
yours to enter);
- **As template** → save the current form's non-key/name fields as a template.
- Templates persist in the runtime file's `source_templates` field alongside
runtime sources (hot-managed, apply immediately). Templates hold no secret
keys, so they're safe to share / version-control.
A template is just a recipe — it doesn't become a source by itself. Only the
key-bearing sources created from it carry real traffic.
### Memory footprint (measured and tuned)
Memory is not a constant: it scales with the **number of configured sources**
(each source owns an `http.Transport` with its connection pool plus per-model
health state), not with uptime. Measured on this machine (Linux x86_64, 12 cores):
| Deployment shape | Startup RSS | Settled RSS |
|---|---|---|
| 1 source / 1 adapter (minimal) | ~4 MB | ~10 MB |
| 1 source + a 29 MB audit history | ~19 MB | ~20 MB |
| **16 sources / 13 adapters / 59 models (this host)** | ~28 MB | **~32-35 MB** |
> For reference, the same production config used **~105 MB** before this round of
> work. The reduction comes from three places: raw audit records are no longer kept
> resident (~25 MB — the aggregates still scan the full history, but the scan
> releases as it goes), Lua state pools no longer grow monotonically, and the two
> runtime knobs below.
Breakdown of the production instance (per-region, from `/proc/<pid>/smaps`):
| Region | RSS | Notes |
|---|---|---|
| Go heap | ~14 MB | provider/registry/scheduler state + connection-pool buffers |
| Other anonymous (thread stacks / LuaJIT chunks / runtime) | ~12 MB | scales with thread count and loaded adapters |
| Binary text+rodata | ~8 MB | mapped executable pages (read-only, reclaimable by the kernel) |
| Go runtime reservations | ~4 MB | the 2 GB+ you see in VSZ is address space, not physical memory |
| Shared libraries | ~3 MB | libc / libluajit / libm |
**Two recommended deployment knobs** (environment only, no code change):
```ini
# /etc/systemd/system/llmsproxy.service
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
```
- `MALLOC_ARENA_MAX=2`: LuaJIT allocates through cgo into glibc malloc, and glibc
allows up to `8 x nproc` per-thread arenas. Every OS thread that touches malloc
claims one (~1 MB each) and **never returns it to the OS**. Measured: 8-15
arenas (7-12 MB) down to **0**.
- `GOGC=50`: halves the Go heap growth target. **It does nothing on its own** —
the heap it saves is immediately consumed by additional glibc arenas (measured
20.3 -> 21.5 MB, i.e. slightly worse) — so it must be paired with
`MALLOC_ARENA_MAX`. Together they cut settled RSS by **~19%**. The gateway is
I/O bound (1min10s of CPU per 9 hours here), so the extra GC cycles are free.
> The desktop build (Electron) already injects both when spawning the embedded
> core (`cmd/gui/main.js`); exporting either variable yourself overrides it. If you
> write your own systemd unit or container spec, add them there.
Optional: `GOMEMLIMIT=48MiB` as a soft ceiling saves roughly 1 MB more, at the
cost of GC turning aggressive as the limit approaches. Not worth it for a relaxed
deployment.
### disable_thinking
With `"disable_thinking": true` in the request body, the gateway passes it to
each adapter; the DeepSeek adapter maps it to `extra_body.thinking.type =
"disabled"`, other sources follow their own protocol.
### WebUI
Built-in admin page at `GET /`; after login you can:
- **Status**: source online state (live probe, error on hover), per
model/source/key usage stats, request records filtered by key, and CSV export
over a time range; click a model to pin the connection snippet to it.
- **Chat**: streaming / non-streaming debug.
- **Keys**: create/edit gateway keys, set per-key model scopes (model + source +
token quota + reset period); admins manage all keys, users see only their own.
- **Priority**: drag blocks to build the tiered AUTO chain.
- **Sources**: add/edit/delete upstream sources online (secrets stored
encrypted).
- **Adapters**: upload / delete Lua adapter scripts.
Changes persist to `runtime_file` (survive restarts).
## Lua adapter protocol
Full API: **[Lua adapter docs](docs/lua-adapters.md)**
([中文](docs/lua-adapters-en.md)).
Each adapter is a Lua script returning a table
(`internal/lua/adapters/<name>.lua`), overridable by a same-name file under
`adapter_dir` — **no recompile needed**, restart to take effect; uploads via
the WebUI take effect immediately.
```lua
return {
name = "mysrc",
version = "1.0.0",
endpoint = "/chat/completions", -- upstream path (source.endpoint overrides)
headers = { ["X-Static"] = "v" }, -- static default headers (fallback)
-- request: convert unified OpenAI req -> upstream native format, return string
transform_request = function(raw_json) ... end,
-- response: convert upstream response to unified format string
-- { content, reasoning_content, finish_reason, token_usage{...}, tool_calls[{...}] }
transform_response = function(raw_json) ... end,
-- streaming chunk: convert upstream SSE data to { content, done, ... }; "" skips
transform_stream_chunk = function(raw_chunk) ... end,
-- [optional] dynamic headers / signature hook
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
}
```
Shared helpers: `hmac_sha256_hex(key, data)`, `sha256_hex(data)`,
`base64_encode(s)`, `tohex(s)`, `json.encode/decode`, `log(level, msg)`.
### Built-in adapters
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama`
`kimicode` `qwen38` `sensenova` `trae`.
`anthropic`/`gemini`/`ollama` include multimodal conversion
(`image_url` → their native format); with `disable_thinking` the `deepseek`
adapter sets `extra_body.thinking.type` to `disabled`.
**kimicode** demonstrates `build_headers`: the cloud validates the calling
app, so you HMAC-sign timestamp+URL+body with `meta.app_secret` and add
`X-App-Sign`-style headers. Configure `sources[].meta.{app_id, app_secret,
app_agent}`.
## Desktop GUI (Electron)
Optional standalone product for desktop users on your internal network: an
**embedded ModelRouter core** (Clash Verge-style): one-click start/stop, system
tray, autostart, silent launch, and a pixel-for-pixel embedded full WebUI
(Status/Chat/Keys/Priority/Sources/Adapters — no login needed). Server users
keep using the plain Go binary.
### Two installer kinds: Headless and Desktop
Release installers ship in two flavors for different audiences:
- **Headless (server)**: the plain core binary (no Electron), config-driven,
for servers / containers / systemd / unattended runs. Ships as `deb` + `rpm`
(systemd unit with memory tuning + example config) and `tar.gz` (binary +
`config.example.yaml` + `adapters/` + README), Windows as `.zip`.
- **Desktop (desktop app)**: the Electron GUI installer with an embedded core,
for personal daily desktop use.
### GUI vs. plain backend
| Scenario | Pick | Why |
| ---- | ---- | ---- |
| Server / intranet gateway / unattended long-running | **Headless** (single binary) | ~8 MB binary; RSS scales with the number of sources (~10 MB for one, ~32-35 MB for 16 — see [Memory footprint](#memory-footprint-measured-and-tuned)), zero-dep single process — drop it into systemd or any container, remote admin |
| Personal desktop daily use / multi-device intranet sharing | **Desktop** (GUI) | no-login embedded WebUI, tray one-click, autostart, silent background — for non-CLI users |
| Windows desktop | **Desktop** | plain backend needs manual service registration; GUI ships native tray/autostart |
| CI one-shot 3-platform installers | **Desktop packaging scripts** | `make gui-*` emits deb / AppImage / NSIS, drops straight into a release pipeline |
Both share the exact same `llmsproxy` core (LuaJIT build) — configs and
adapters are fully compatible, freely interchangeable.
### Build & run
Any OpenAI SDK works: point `base_url` at the gateway and use an authorized gateway
key as `api_key`.
## Documentation
The detailed documentation is currently written in Chinese.
| Topic | Document |
|---|---|
| Implementation and the full feature list | [docs/features.md](docs/features.md) |
| Configuration: `config.yaml`, model discovery, multiple keys | [docs/configuration.md](docs/configuration.md) |
| Routing: model routing, AUTO chain, cooldown, sources vs adapters, source templates | [docs/routing.md](docs/routing.md) |
| Adapters: Lua protocol and built-in adapters | [docs/adapters.md](docs/adapters.md) · [docs/lua-adapters-en.md](docs/lua-adapters-en.md) (English) |
| Plugins: SDK contract, hooks, UI extensions, HTTP endpoints | [docs/plugins.md](docs/plugins.md) |
| Desktop app: Headless / Desktop artifacts, build and run | [docs/gui.md](docs/gui.md) |
| Performance: measured memory use, tuning, thinking budget | [docs/performance.md](docs/performance.md) |
| WebUI usage | [docs/webui.md](docs/webui.md) |
| Development: layout, tests, branch and release discipline | [docs/development.md](docs/development.md) · [docs/git-workflow-en.md](docs/git-workflow-en.md) (English) |
| Deployment and migration | [docs/deployment.md](docs/deployment.md) |
## Renamed from llmsproxy
The product name has been ModelRouter for a while; the binary, config directory,
service name and environment variables are now `modelrouter` as well:
| Before | After |
|---|---|
| `llmsproxy` (binary) | `modelrouter` |
| `/etc/llmsproxy/` | `/etc/modelrouter/` |
| `llmsproxy.service` | `modelrouter.service` |
| `LLMSPROXY_*` | `MODELROUTER_*` |
Existing installs migrate with a dry-run-by-default script (copies, never moves,
and can roll back):
```bash
cd cmd/gui
npm install
make gui # or npm run dev — dev run (needs make build first for bin/llmsproxy)
make gui-deb # local deb (share with other Linux users)
make gui-dist # deb + rpm + AppImage (linux; rpm needs system rpmbuild)
make core-dist # core deb + rpm + tar.gz (server edition; needs nfpm)
make gui-win # Windows NSIS (host mingw + wine)
make gui-win-docker # Windows NSIS, fully dockerized (wine in image; no host mingw/node/wine)
sudo ./packaging/migrate-to-modelrouter.sh # preview
sudo ./packaging/migrate-to-modelrouter.sh --apply # apply
sudo ./packaging/migrate-to-modelrouter.sh --rollback --apply
```
Artifacts land in `cmd/build/gui-dist/`: `ModelRouter-<ver>.AppImage`,
`modelrouter-gui_<ver>_amd64.deb`, `ModelRouter Setup <ver>.exe` (win); core
packages land in `cmd/build/dist/`: `llmsproxy_<ver>_amd64.deb`,
`llmsproxy-<ver>.x86_64.rpm`. Both target families run
`packaging/verify-dist.sh` afterwards — a size floor that rejects degenerate
installers (1.3.0 shipped a 264 KB NSIS stub because the host wine was broken
and nothing caught it). At release time artifacts are renamed into the
`ModelRouter-Desktop-*` (GUI) and `ModelRouter-Headless-*` (plain backend)
families before upload to GitCode Releases.
Old paths and old environment variable names are still accepted on the read side,
so an upgrade cannot fail merely because of ordering. `deploy.sh` refuses to run
when it finds the old `/etc/llmsproxy` without a new config, because that would
deploy a gateway with no key and no sources.
#### Windows packaging (dockerized, recommended)
`make gui-win-docker` does cross-compile + NSIS **entirely in docker**; the host
needs **no mingw, no wine, no node**. Wine (with the i386 runtime) lives inside
the image, fixing the broken-host-wine failure that produced the 264 KB stub
installer.
- Image: `cmd/gui/docker/win-builder/Dockerfile` (`golang:1.25` + mingw-w64 +
node + wine32/wine64 + prebuilt LuaJIT for Windows: `lua51.dll` + import lib).
LuaJIT source comes from the gitcode mirror (reachable in CN) with github
fallback; Go modules go through `https://goproxy.cn`.
- One-shot release: `cmd/gui/scripts/dist-win-docker.sh` — core build + NSIS
packaging run inside the container (`USE_SYSTEM_WINE=true` uses the image's
wine); electron-builder's ~400 MB toolchain cache lives in a docker volume so
later builds reuse it.
- The `modelrouter/win-builder` image is auto-built on first run (~5-10 min)
and reused afterwards.
- After packaging, `packaging/verify-dist.sh` runs as a gate: an exe under
5 MB (or a deb/rpm/7z under 10 MB) fails the build — a broken installer can
never reach a Release again.
```bash
# Full Windows release (rebuild core + NSIS):
make gui-win-docker
```
### Features
- **Embedded core**: auto-spawns the bundled `llmsproxy` (LuaJIT build);
config/keys/adapters live in `<userData>/profile/`. First run generates a
random admin key written into `keys` (not seed) and self-injects it — WebUI
needs no login and never nags about changing the initial key. Port changeable
in settings (default 8787).
- **System tray**: core status dot, autostart/silent toggles, start/stop/restart
core, quit.
- **Silent launch**: `--silent` flag or setting — starts to the tray only;
first run always shows the window.
- **Autostart**: `setLoginItemSettings` on Windows/macOS; writes
`~/.config/autostart/modelrouter-gui.desktop` on Linux, appending `--silent`
when silent mode is on.
The plugin SDK follows the same naming: the only public entry point is
`modelrouter` (`llmsproxy` remains a compatibility alias). See
[docs/plugins.md](docs/plugins.md).
## Layout
```
cmd/llmsproxy # entry point
internal/config # YAML config loading/validation
internal/lua # LuaJIT VM + worker pool + AdapterCache + built-ins (embed)
```text
cmd/modelrouter # entry point (pure Go server)
cmd/gui # Electron desktop shell (embeds the same core)
internal/config # config parsing, sealed storage, runtime file
internal/core # scheduling, kernel actions, state and audit
internal/lua # LuaJIT VM + elastic worker pool + built-in adapters (embed)
internal/lua/plugins # optional plugin examples (not shipped in binary/archives)
internal/provider # Provider(HTTP) + Registry(routing)
internal/gateway # OpenAI-compatible HTTP + auth + SDK/streaming + WebUI
internal/types # unified format & OpenAI wire types
internal/gateway # OpenAI-compatible HTTP service + auth + streaming + WebUI
internal/types # unified format and OpenAI wire types
packaging/ # deb/rpm/tar packaging, systemd unit, migration script
docs/ # topic documentation
```
## Development & collaboration
- **Git branching workflow**: main / feature / release-branch conventions in
**[docs/git-workflow-en.md](docs/git-workflow-en.md)**
([中文](docs/git-workflow.md)). The line: `main` is always deployable →
features merge into `main` → cut `release/vX.Y.Z` and tag → release → hotfixes
land on the release branch and are cherry-picked back into `main`.
- **Lua adapter protocol**: see
**[docs/lua-adapters-en.md](docs/lua-adapters-en.md)**
([中文](docs/lua-adapters.md)).
## Tests
## Development and tests
```bash
go test -tags luajit ./...
go test -tags luajit ./... # everything
go test -tags luajit ./internal/lua # one package (required for LuaJIT changes)
```
Covers: config validation, adapter load/transform, signature hooks, gateway
auth, SDK round trip, SSE streaming, model routing, multimodal passthrough and
disable_thinking.
- Branch and release discipline: [docs/git-workflow-en.md](docs/git-workflow-en.md).
- Plugins live in `plugin_dir`. The gateway never creates, overwrites or deletes
plugin files, and a desktop upgrade never touches them either.

View File

@ -1,5 +1,5 @@
// ModelRouter Desktop GUI — main process
// Clash-Verge-style: bundles and manages an embedded llmsproxy core instance
// Desktop shell: bundles and manages an embedded modelrouter core instance
// in a per-user profile dir (userData), auto-starts it, keeps a tray icon,
// supports launch-at-login. Server-side users keep using the pure Go binary.
const {
@ -18,6 +18,7 @@ const http = require("http");
const os = require("os");
const crypto = require("crypto");
const { spawn, execFileSync } = require("child_process");
const pluginSync = require("./plugin-sync");
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
function log(msg) {
@ -40,13 +41,36 @@ console.error = function () {
// Per-user profile dir: <userData>/profile
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
// Plugin tree for the embedded core. The core deliberately does NOT default
// plugin_dir (an operator opts in), so the desktop build must set it — without
// it the whole plugin system stays off and the channels plugin (multi-platform
// account login + credential vault + auto check-in) is invisible in the WebUI.
// Seeding is the core's job (SeedBundled fills MISSING files only), so a user's
// edited copy always wins.
const PLUGINS_DIR = path.join(PROFILE_DIR, "plugins");
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
// Dev: cmd/gui/bin, populated by prepare-core.js.
const CORE_EXE = path.join(
// The embedded core is named after the product. Installs produced before the
// llmsproxy -> modelrouter rename still carry the old file name inside their
// profile, so resolution falls back to it instead of declaring the core missing.
const CORE_BIN_DIR = path.join(
app.isPackaged ? process.resourcesPath : __dirname,
"bin",
);
const CORE_EXE = path.join(
CORE_BIN_DIR,
process.platform === "win32" ? "modelrouter.exe" : "modelrouter",
);
const LEGACY_CORE_EXE = path.join(
CORE_BIN_DIR,
process.platform === "win32" ? "llmsproxy.exe" : "llmsproxy",
);
function resolveCoreExe() {
if (fs.existsSync(CORE_EXE)) return CORE_EXE;
if (fs.existsSync(LEGACY_CORE_EXE)) return LEGACY_CORE_EXE;
return CORE_EXE;
}
// Packaged window icon: must be a REAL file on disk (extraResource), not an
// asar path — Electron/GTK pulls BrowserWindow icons from the filesystem and
// crashes (SIGSEGV) when given an asar virtual path on Linux.
@ -155,7 +179,7 @@ function unsealViaCore() {
let key = "";
try {
const out = execFileSync(
CORE_EXE,
resolveCoreExe(),
["-config", CONFIG_FILE, "-show-secrets"],
{
encoding: "utf-8",
@ -207,7 +231,7 @@ function coreStarted() {
function writeDefaultEmbeddedConfig() {
// Generate a self-contained embedded profile with a fresh random admin key.
// No upstream sources are preconfigured — the desktop user adds them via
// the WebUI's sources page (like Clash Verge's empty-by-default profile).
// the WebUI's sources page (empty by default, like the WebUI profile).
// If a config already exists we do NOT overwrite it (keep user's edits).
if (fs.existsSync(CONFIG_FILE)) return;
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
@ -216,6 +240,9 @@ function writeDefaultEmbeddedConfig() {
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
"default_model: AUTO",
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
// Plugin tree: channels/billing/quota/usage ship inside the core binary and
// are seeded into this dir on first start. Omitting it disables plugins.
"plugin_dir: " + JSON.stringify(PLUGINS_DIR),
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
"",
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
@ -261,15 +288,18 @@ function startCore() {
console.log("core already running");
return;
}
if (!fs.existsSync(CORE_EXE)) {
console.error("embedded core binary missing:", CORE_EXE);
const coreExe = resolveCoreExe();
if (!fs.existsSync(coreExe)) {
console.error("embedded core binary missing:", coreExe);
return;
}
// ensure profile dir + config + local adapters exist
fs.mkdirSync(PROFILE_DIR, { recursive: true });
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
pluginSync.ensurePluginDirConfigured(CONFIG_FILE, PLUGINS_DIR, log);
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
coreProc = spawn(coreExe, ["-config", CONFIG_FILE], {
cwd: PROFILE_DIR,
env: Object.assign({}, process.env, {
LLMS_PROXY_GUI: "1",
@ -622,12 +652,57 @@ function createWindow() {
},
});
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
// External links ALWAYS go to the user's default browser, never an in-app
// window. Three paths have to be covered, and missing any one of them is why
// "click the login link and nothing happens / the app navigates away" shows up:
// 1. window.open / target=_blank -> setWindowOpenHandler
// 2. top-level navigation (same-tab click) -> will-navigate
// 3. navigation inside the WebUI <iframe> -> will-frame-navigate
// The embedded core's own origin (and file:// for the shell) must keep
// working, otherwise the WebUI and plugin pages stop loading.
const isInternal = (url) =>
!url ||
url === "about:blank" ||
url.startsWith("file://") ||
url.startsWith(embeddedBaseUrl()) ||
url.startsWith("devtools://");
const openExternally = (url) => {
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
log("[gui] open in default browser: " + url);
shell.openExternal(url);
}
};
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url && url.startsWith("http")) shell.openExternal(url);
if (!isInternal(url)) openExternally(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (e, url) => {
if (isInternal(url)) return;
e.preventDefault();
openExternally(url);
});
// iframe navigations (the WebUI content area and the plugin pages inside it).
// The channels plugin renders its server-side login browser (noVNC) in an
// iframe on a LOOPBACK port, so loopback MUST stay in-app — blocking it would
// break VNC login. Everything non-loopback is external: open the default
// browser instead of silently replacing the gateway UI with a third-party page.
const isLoopback = (url) => {
try {
const h = new URL(url).hostname;
return h === "127.0.0.1" || h === "localhost" || h === "[::1]";
} catch (e) {
return false;
}
};
if (mainWindow.webContents.on) {
mainWindow.webContents.on("will-frame-navigate", (e) => {
if (isInternal(e.url) || isLoopback(e.url)) return;
e.preventDefault();
openExternally(e.url);
});
}
// Silent start: `--silent` CLI flag or settings.silentStart keeps the app
// in the tray without showing the window (Clash-Verge style). First run
// in the tray without showing the window (desktop-shell behaviour). First run
// always shows the window so the user sees the dashboard.
mainWindow.once("ready-to-show", () => {
const firstRun = !fs.existsSync(
@ -751,8 +826,8 @@ ipcMain.handle("core:state", () => ({
port: settings.port || DEFAULT_PORT,
profileDir: PROFILE_DIR,
configFile: CONFIG_FILE,
coreExe: CORE_EXE,
coreExists: fs.existsSync(CORE_EXE),
coreExe: resolveCoreExe(),
coreExists: fs.existsSync(resolveCoreExe()),
}));
ipcMain.handle("core:start", () => {
app.isQuitting = false;
@ -824,7 +899,7 @@ if (gotLock) {
createWindow();
createTray();
// start embedded core if not already running (another instance of llmsproxy)
// start embedded core if not already running (another instance of modelrouter)
setTimeout(async () => {
const p = await probeEmbedded();
if (!p.ok) {

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "3.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.9.3",
"version": "3.0.2",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",
@ -1245,7 +1245,7 @@
}
},
"node_modules/chownr": {
"version": "3.0.0",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
@ -2288,7 +2288,7 @@
}
},
"node_modules/global-agent": {
"version": "3.0.0",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
@ -2532,7 +2532,7 @@
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
@ -2623,7 +2623,7 @@
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
@ -2715,7 +2715,7 @@
}
},
"node_modules/matcher": {
"version": "3.0.0",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
@ -3492,7 +3492,7 @@
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
@ -3612,7 +3612,7 @@
}
},
"node_modules/sumchecker": {
"version": "3.0.1",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
"dev": true,

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.9.7",
"version": "3.0.2",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",
@ -39,8 +39,8 @@
},
"extraResources": [
{
"from": "bin/llmsproxy",
"to": "bin/llmsproxy"
"from": "bin/modelrouter",
"to": "bin/modelrouter"
},
{
"from": "build/icon.png",
@ -66,8 +66,8 @@
"icon": "build/icon.ico",
"extraResources": [
{
"from": "bin/llmsproxy.exe",
"to": "bin/llmsproxy.exe"
"from": "bin/modelrouter.exe",
"to": "bin/modelrouter.exe"
},
{
"from": "bin/lua51.dll",
@ -81,6 +81,7 @@
},
"files": [
"main.js",
"plugin-sync.js",
"preload.js",
"renderer/**/*",
"build/**/*",
@ -88,5 +89,5 @@
],
"asar": true
},
"homepage": "https://gitcode.com/JianFeeeee/ModelRouter"
"homepage": "https://atomgit.com/HeNanMU-EEA/ModelRouter"
}

33
cmd/gui/plugin-sync.js Normal file
View File

@ -0,0 +1,33 @@
// Plugin-tree maintenance for the desktop profile (no Electron imports, so this
// is unit-testable with plain node).
//
// The desktop may configure an operator-selected plugin directory, but it never
// owns the files in that directory. In particular, upgrades must not seed,
// replace, rename, or delete plugins. Plugin installation is an explicit
// operator action (or an external package manager action).
const fs = require("fs");
// ensurePluginDirConfigured back-fills `plugin_dir` into an existing config.
// Appending a top-level YAML key is safe for the shapes this file can have
// (flat mapping + a keys list). Returns "added" | "present" | "absent" | "error".
function ensurePluginDirConfigured(configFile, pluginsDir, log) {
try {
if (!fs.existsSync(configFile)) return "absent";
const cur = fs.readFileSync(configFile, "utf-8");
if (/^\s*plugin_dir\s*:/m.test(cur)) return "present";
fs.writeFileSync(
configFile,
cur.replace(/\s*$/, "") +
"\n# added by ModelRouter Desktop: enables the operator plugin directory\n" +
"plugin_dir: " + JSON.stringify(pluginsDir) + "\n",
"utf-8",
);
if (log) log("[gui] plugin_dir back-filled into " + configFile);
return "added";
} catch (e) {
if (log) log("[gui] ensurePluginDirConfigured failed: " + e.message);
return "error";
}
}
module.exports = { ensurePluginDirConfigured };

View File

@ -0,0 +1,48 @@
// Unit tests for desktop plugin-directory configuration (plain node).
const test = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { ensurePluginDirConfigured } = require("./plugin-sync");
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sync-"));
test("back-fills plugin_dir into an existing profile without it", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
fs.writeFileSync(cfg, "listen: 127.0.0.1:8787\ndefault_model: AUTO\nkeys:\n - key: k\n role: admin\n");
const plugins = path.join(dir, "plugins");
assert.equal(ensurePluginDirConfigured(cfg, plugins, null), "added");
const out = fs.readFileSync(cfg, "utf-8");
assert.match(out, /^plugin_dir: "/m);
assert.match(out, /^listen: 127.0.0.1:8787$/m);
});
test("leaves a profile that already sets plugin_dir alone", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
const orig = 'plugin_dir: "/custom/plugins"\nlisten: ":1"\n';
fs.writeFileSync(cfg, orig);
assert.equal(ensurePluginDirConfigured(cfg, "/whatever", null), "present");
assert.equal(fs.readFileSync(cfg, "utf-8"), orig);
});
test("missing config is not created by the back-fill", () => {
const dir = tmp();
const cfg = path.join(dir, "config.yaml");
assert.equal(ensurePluginDirConfigured(cfg, path.join(dir, "p"), null), "absent");
assert.equal(fs.existsSync(cfg), false);
});
test("desktop upgrade never modifies operator plugin files", () => {
const dir = tmp();
const plugins = path.join(dir, "plugins");
fs.mkdirSync(plugins, { recursive: true });
fs.writeFileSync(path.join(plugins, "channels.lua"), "OPERATOR FILE");
fs.writeFileSync(path.join(plugins, "my-own.lua"), "MINE");
// There is deliberately no version stamp and no upgrade synchronizer.
assert.equal(fs.readFileSync(path.join(plugins, "channels.lua"), "utf-8"), "OPERATOR FILE");
assert.equal(fs.readFileSync(path.join(plugins, "my-own.lua"), "utf-8"), "MINE");
assert.equal(fs.readdirSync(dir).includes(".plugin-seed-version"), false);
});

View File

@ -1,5 +1,5 @@
// ModelRouter Desktop GUI — renderer
// Clash-Verge-style: the content area is a full-screen iframe loading the
// Desktop shell: the content area is a full-screen iframe loading the
// real WebUI 1:1 (status / chat / keys / priority / sources / adapters).
// The shell only adds the titlebar, settings overlay and tray integration.
const state = {

View File

@ -83,7 +83,7 @@
</div>
</div>
<!-- side rail: autostart / silent toggles (Clash-Verge style) -->
<!-- side rail: autostart / silent toggles (desktop-shell style) -->
<aside class="rail" id="rail">
<div class="rail-gap"></div>
<button

View File

@ -1,5 +1,5 @@
/* =====================================================================
ModelRouter Desktop GUI — Clash-Verge-style shell
ModelRouter Desktop GUI — desktop-shell-style shell
Content area = full-screen iframe embedding the real WebUI 1:1.
Palette mirrors the WebUI (sakura/frost/glassmorphism).
===================================================================== */

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# One-shot Windows release build for the GUI — ENTIRELY inside docker.
#
# 1) cross-compile the embedded core in docker (llmsproxy.exe + lua51.dll)
# 1) cross-compile the embedded core in docker (modelrouter.exe + lua51.dll)
# 2) electron-builder NSIS packaging ALSO inside docker (wine for the
# uninstaller step lives in the image — see Dockerfile). This is the fix
# for the 1.3.0/1.4.0 broken installers: the host had amd64-only wine
@ -37,13 +37,15 @@ docker run --rm \
-v "$ROOT":/workspace \
-v "$CACHE_VOL":/root/.cache \
-e GOPROXY="$PROXY" \
-e ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" \
-e ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/" \
-w /workspace \
"$IMAGE" bash -c '
set -euo pipefail
cp /opt/luajit2/src/lua51.dll /workspace/cmd/gui/bin/lua51.dll
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc \
go build -buildvcs=false -tags luajit -trimpath \
-o /workspace/cmd/gui/bin/llmsproxy.exe ./cmd/llmsproxy
go build -buildvcs=false -tags luajit -trimpath -ldflags "-s -w" \
-o /workspace/cmd/gui/bin/modelrouter.exe ./cmd/modelrouter
echo " -> core built"
# wine needs one prefix initialization so its wow64 runtime (syswow64/ntdll)
# exists — the exact thing that was missing on the broken host. Use a shared

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node
// Build the embedded llmsproxy core(s) for the GUI package.
// Build the embedded modelrouter core(s) for the GUI package.
// node prepare-core.js -> linux (native, luajit) + win (cross, best effort)
// node prepare-core.js linux -> linux only
// node prepare-core.js win -> win only
@ -12,21 +12,24 @@ const binDir = path.join(guiDir, "bin");
const projectRoot = path.join(guiDir, "..", "..");
function buildLinux() {
const out = path.join(binDir, "llmsproxy");
console.log("[prepare-core] building llmsproxy (linux/luajit)...");
const out = path.join(binDir, "modelrouter");
console.log("[prepare-core] building modelrouter (linux/luajit)...");
fs.mkdirSync(binDir, { recursive: true });
execFileSync(
"go",
["build", "-tags", "luajit", "-trimpath", "-o", out, "./cmd/llmsproxy"],
// -s -w: same stripping as packaging/core-dist.sh. Without it the bundled
// core carries debug info (~14.4 MB vs ~10.3 MB) and every installer pays
// for it, for a binary the user never debugs.
["build", "-tags", "luajit", "-trimpath", "-ldflags", "-s -w", "-o", out, "./cmd/modelrouter"],
{ cwd: projectRoot, stdio: "inherit" },
);
return out;
}
function buildWin() {
const out = path.join(binDir, "llmsproxy.exe");
const out = path.join(binDir, "modelrouter.exe");
console.log(
"[prepare-core] cross-building llmsproxy.exe (windows/amd64, luajit)...",
"[prepare-core] cross-building modelrouter.exe (windows/amd64, luajit)...",
);
fs.mkdirSync(binDir, { recursive: true });
const cc = process.env.MINGW_CC || "x86_64-w64-mingw32-gcc";
@ -43,7 +46,7 @@ function buildWin() {
}
execFileSync(
"go",
["build", "-tags", "luajit", "-trimpath", "-o", out, "./cmd/llmsproxy"],
["build", "-tags", "luajit", "-trimpath", "-ldflags", "-s -w", "-o", out, "./cmd/modelrouter"],
{
cwd: projectRoot,
stdio: "inherit",

View File

@ -1,4 +1,4 @@
// Command llmsproxy is a standalone gateway that exposes multiple upstream LLM
// Command modelrouter is a standalone gateway that exposes multiple upstream LLM
// sources behind a unified OpenAI-compatible HTTP API. Protocol differences are
// handled by Lua adapters (per source), optionally signing outgoing requests.
// It supports explicit model selection or AUTO routing by priority, image
@ -16,9 +16,9 @@ import (
"syscall"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
"llmsproxy/internal/gateway"
"modelrouter/internal/config"
"modelrouter/internal/core"
"modelrouter/internal/gateway"
)
func main() {
@ -33,12 +33,12 @@ func main() {
// error here instead of being silently created.
if *checkOnly {
if _, err := os.Stat(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] check: %v", err)
log.Fatalf("[modelrouter] check: %v", err)
}
if _, err := config.Load(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] check: %v", err)
log.Fatalf("[modelrouter] check: %v", err)
}
log.Printf("[llmsproxy] check: %s is valid", *cfgPath)
log.Printf("[modelrouter] check: %s is valid", *cfgPath)
return
}
@ -48,34 +48,34 @@ func main() {
// program prints a credential, and this path never logs to a file.
if *showSecrets {
if _, err := os.Stat(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] show-secrets: %v", err)
log.Fatalf("[modelrouter] show-secrets: %v", err)
}
if err := config.PrintSecrets(*cfgPath); err != nil {
log.Fatalf("[llmsproxy] show-secrets: %v", err)
log.Fatalf("[modelrouter] show-secrets: %v", err)
}
return
}
created, err := config.EnsureDefault(*cfgPath)
if err != nil {
log.Fatalf("[llmsproxy] config: %v", err)
log.Fatalf("[modelrouter] config: %v", err)
}
c, err := core.New(*cfgPath)
if err != nil {
log.Fatalf("[llmsproxy] core: %v", err)
log.Fatalf("[modelrouter] core: %v", err)
}
defer c.Close()
if created {
for _, k := range c.GatewayKeys() {
log.Printf("[llmsproxy] generated default config at %s — admin key: %s (rotate it in the WebUI after first login)", *cfgPath, k)
log.Printf("[modelrouter] generated default config at %s — admin key: %s (rotate it in the WebUI after first login)", *cfgPath, k)
}
}
gw, err := gateway.New(c)
if err != nil {
log.Fatalf("[llmsproxy] gateway: %v", err)
log.Fatalf("[modelrouter] gateway: %v", err)
}
// Ops hygiene: surface the two most common footguns instead of silently
@ -88,16 +88,16 @@ func main() {
// (e.g. after rotating the starter key away) it reported "every request is
// rejected" while seven working keys, one of them admin, were in service.
if keys := c.ListKeys(); len(keys) == 0 {
log.Printf("[llmsproxy] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys")
log.Printf("[modelrouter] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys")
} else {
for _, k := range keys {
if k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" {
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
log.Printf("[modelrouter] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
}
}
}
if l := c.Listen(); strings.HasPrefix(l, "0.0.0.0:") || strings.HasPrefix(l, "::") {
log.Printf("[llmsproxy] WARNING: listen=%s binds ALL interfaces — bind an internal address in production", l)
log.Printf("[modelrouter] WARNING: listen=%s binds ALL interfaces — bind an internal address in production", l)
}
srv := &http.Server{
@ -108,22 +108,22 @@ func main() {
go func() {
cert, key := c.TLS()
if cert != "" && key != "" {
log.Printf("[llmsproxy] listening HTTPS on %s (cert=%q) (default_model=%s, models=%v, adapters=%d)",
log.Printf("[modelrouter] listening HTTPS on %s (cert=%q) (default_model=%s, models=%v, adapters=%d)",
c.Listen(), cert, c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
if err := srv.ListenAndServeTLS(cert, key); err != nil && err != http.ErrServerClosed {
log.Fatalf("[llmsproxy] server: %v", err)
log.Fatalf("[modelrouter] server: %v", err)
}
return
}
log.Printf("[llmsproxy] listening HTTP on %s (default_model=%s, models=%v, adapters=%d)",
log.Printf("[modelrouter] listening HTTP on %s (default_model=%s, models=%v, adapters=%d)",
c.Listen(), c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[llmsproxy] server: %v", err)
log.Fatalf("[modelrouter] server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("[llmsproxy] shutting down")
log.Printf("[modelrouter] shutting down")
}

212
deploy.sh
View File

@ -1,10 +1,10 @@
#!/usr/bin/env bash
# ============================================================
# llmsproxy 原子化滚动部署脚本
# modelrouter 原子化滚动部署脚本
#
# 策略:
# 1. /tmp (tmpfs) 构建,/usr/local/bin (ext4) 部署
# 2. 构建后先 copy 到目标文件系统(.llmsproxy.tmp),
# 2. 构建后先 copy 到目标文件系统(.modelrouter.tmp),
# 再 rename(2) 做原子替换(同一文件系统内 mv 是原子的)
# 3. runtime.json 不替换(保留运行时密钥、审计状态)
# 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms)
@ -28,20 +28,21 @@
set -euo pipefail
# ---------- 配置 ----------
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
readonly REPO_DIR="/home/program/llmsproxy"
readonly TARGET_BIN="/usr/local/bin/modelrouter"
readonly TARGET_CONFIG="/etc/modelrouter/config.yaml"
readonly TARGET_ADAPTERS="/etc/modelrouter/adapters"
readonly TARGET_PLUGINS="/etc/modelrouter/plugins"
# 仓库所在目录不等于产品名:这里保持真实路径,除非用 MODELROUTER_REPO_DIR 覆盖。
readonly REPO_DIR="${MODELROUTER_REPO_DIR:-/home/program/llmsproxy}"
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
readonly BIN_NAME="llmsproxy"
readonly BIN_NAME="modelrouter"
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
# 可用 MODELROUTER_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
readonly BUILD_SUBDIR=".build-work"
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
if [[ -n "${MODELROUTER_BUILD_DIR:-}" ]]; then
readonly TMP_DIR="${MODELROUTER_BUILD_DIR}"
else
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
fi
@ -59,7 +60,7 @@ usage() {
cat <<'EOF'
用法: ./deploy.sh [--config <file>]
--config <file> 投放这份配置到 /etc/llmsproxy/config.yaml(原子替换)。
--config <file> 投放这份配置到 /etc/modelrouter/config.yaml(原子替换)。
省略时不改动线上配置,只部署二进制与适配器。
无论是否指定,重启前都会用新二进制 -check 预检线上配置。
@ -103,6 +104,34 @@ precheck() {
command -v systemctl &>/dev/null || fail "未找到 systemctl"
[[ $(id -u) -eq 0 ]] || fail "需要 root 权限"
# 改名保护:本脚本现在部署到 /etc/modelrouter。若机器上仍是改名前的
# /etc/llmsproxy 且新路径尚未建立,直接继续会创建一份**空配置**并让新
# 服务在没有 key/源的情况下起来 —— 看起来成功,实际网关拒掉所有请求。
if [[ -d /etc/llmsproxy && ! -e "$TARGET_CONFIG" ]]; then
fail "检测到改名前的部署 /etc/llmsproxy,但 $TARGET_CONFIG 不存在。
先迁移(dry-run 默认,确认后加 --apply):
sudo ./packaging/migrate-to-modelrouter.sh
sudo ./packaging/migrate-to-modelrouter.sh --apply
如需全新部署,先把旧目录移开:mv /etc/llmsproxy /etc/llmsproxy.bak"
fi
# unit 与二进制路径必须一致。包安装把二进制放 /usr/bin,源码部署放
# /usr/local/bin;unit 里写错一行,systemctl restart 只会给 203/EXEC,
# 看起来像"部署成功但服务起不来"。此处提前拦住。
local unit="/etc/systemd/system/modelrouter.service"
if [[ -f "$unit" ]]; then
local unit_exe
unit_exe=$(awk -F'=' '/^ExecStart=/{print $2; exit}' "$unit" | awk '{print $1}')
if [[ -n "$unit_exe" && "$unit_exe" != "$TARGET_BIN" && ! -x "$unit_exe" ]]; then
fail "unit 的 ExecStart 指向 $unit_exe,但该文件不存在。
包安装(deb/rpm)用 /usr/bin/modelrouter;源码部署(本脚本)用 $TARGET_BIN。
请把 $unit 的 ExecStart 改成 $TARGET_BIN,或改用包安装。"
fi
if [[ -n "$unit_exe" && "$unit_exe" != "$TARGET_BIN" ]]; then
warn "unit ExecStart=$unit_exe,而本脚本部署到 $TARGET_BIN(两者都存在,确认哪个该生效)"
fi
fi
if [[ -n "$NEW_CONFIG_SRC" ]]; then
[[ -f "$NEW_CONFIG_SRC" ]] || fail "待部署配置不存在: $NEW_CONFIG_SRC"
# 同一文件比对:--config 指向线上文件本身时没有"投放"动作可做,
@ -153,7 +182,7 @@ build() {
-trimpath \
-ldflags="-s -w" \
-o "$TMP_DIR/bin/$BIN_NAME" \
./cmd/llmsproxy
./cmd/modelrouter
NEW_BIN="$TMP_DIR/bin/$BIN_NAME"
NEW_SIZE=$(stat -c%s "$NEW_BIN")
@ -254,7 +283,7 @@ verify_config() {
# ---------- 密钥可解封校验 ----------
# config.yaml 里的凭据以 enc:v1: 密文落盘,解密钥匙是 master.key(或
# LLMS_PROXY_MASTER_KEY)。一旦钥匙丢失/错位,配置在**启动时**才会报错,
# MODELROUTER_MASTER_KEY)。一旦钥匙丢失/错位,配置在**启动时**才会报错,
# 那时服务已经处于「二进制已替换 + 配置已替换」的半死状态,只能靠回滚。
# 所以在替换任何东西之前先验:能不能用现有的钥匙解开现有的密文。
#
@ -279,21 +308,22 @@ verify_master_key() {
[[ -n "$runtime_file" ]] || runtime_file="$(dirname "$TARGET_CONFIG")/runtime.json"
key_file="$(dirname "$runtime_file")/master.key"
if [[ -n "${LLMS_PROXY_MASTER_KEY:-}" ]]; then
log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)"
MASTER_KEY_ENV="${MODELROUTER_MASTER_KEY:-${LLMS_PROXY_MASTER_KEY:-}}"
if [[ -n "$MASTER_KEY_ENV" ]]; then
log " 钥匙来源: MODELROUTER_MASTER_KEY(环境变量)"
# 走真实解密路径验证,而不是只检查格式
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
err "凭据无法用 MODELROUTER_MASTER_KEY 解封,未替换任何文件:"
printf '%s\n' "$out" >&2
return 1
fi
if [[ ! -f "$key_file" ]]; then
err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file"
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 LLMS_PROXY_MASTER_KEY 后重试。"
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 MODELROUTER_MASTER_KEY 后重试。"
return 1
fi
log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)"
@ -309,48 +339,60 @@ verify_master_key() {
return 1
}
# ---------- 适配器备份保留策略 ----------
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
# ---------- 备份目录保留策略 ----------
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
# 实测线上已积累 71 个,/etc/modelrouter 因此涨到 122M。
#
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
KEEP_ADAPTER_BACKUPS=5
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
# 就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
#
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
KEEP_BACKUPS=5
prune_adapter_backups() {
local base="/etc/llmsproxy"
prune_backups() {
local prefix="$1"
local base="/etc/modelrouter"
[[ -d "$base" ]] || return 0
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
case "$prefix" in
adapters|plugins) ;;
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
esac
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
local all
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
local cap=$((KEEP_BACKUPS * 4))
if (( ${#all[@]} > cap )); then
warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
warn "$prefix 备份目录有 ${#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
if [[ "$d" =~ ^"$prefix"\.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)
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
fi
# 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
# 再按时间保留最近 KEEP_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
if (( i <= total - KEEP_BACKUPS )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
@ -359,8 +401,15 @@ prune_adapter_backups() {
done
local left
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
}
# prune_all_backups:两个前缀都裁。
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
prune_all_backups() {
prune_backups adapters
prune_backups plugins
}
# ---------- 同步适配器 ----------
@ -374,96 +423,42 @@ sync_adapters() {
[[ -d "$SRC_ADAPTERS" ]] || fail "内嵌适配器目录不存在: $SRC_ADAPTERS"
# 备份旧的适配器文件(含任何运行时覆盖版本,如 opencode.lua)
BACKUP_DIR="/etc/llmsproxy/adapters.bak.$(date +%Y%m%d%H%M%S)"
BACKUP_DIR="/etc/modelrouter/adapters.bak.$(date +%Y%m%d%H%M%S)"
if [[ -d "$TARGET_ADAPTERS" ]]; then
mkdir -p "$BACKUP_DIR"
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
prune_adapter_backups
prune_all_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua
# 校验:每个适配器必须包含 usage 透传修复(opencode/openai/deepseek 等应有 'uses')
local required_files=(openai deepseek anthropic gemini ollama opencode github groq kimicode mistral sensenova agentrouter)
local required_files=(openai deepseek anthropic gemini ollama github groq mistral sensenova qwen38)
for f in "${required_files[@]}"; do
[[ -f "$TARGET_ADAPTERS/$f.lua" ]] || warn " 缺少适配器: $f.lua"
done
local usage_fixed=0
for f in openai deepseek github groq kimicode mistral opencode; do
for f in openai deepseek github groq mistral; do
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
done
log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)"
log " usage 透传修复适配器: ${usage_fixed}/5 (openai/deepseek/github/groq/mistral)"
log "OK 适配器已同步到 $TARGET_ADAPTERS/"
}
# 同步内置插件到 plugin_dir。
# 插件不在这里同步。
#
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
# 保留目录里运行时新增/运维自加的 .lua。
#
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
#
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
sync_plugins() {
log "同步内置插件"
SRC_PLUGINS="$REPO_DIR/internal/lua/plugins"
[[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS"
mkdir -p "$TARGET_PLUGINS"
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
mkdir -p "$BACKUP_DIR"
local added=0 updated=0 same=0
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
((same++)) || true
continue
fi
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " ~ 更新内置插件: $name"
((updated++)) || true
else
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
fi
done
# 没有任何改动时不必留空备份目录
if (( updated == 0 && added == 0 )); then
rmdir "$BACKUP_DIR" 2>/dev/null || true
else
log " 旧插件已备份到 $BACKUP_DIR"
fi
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
}
# 早期实现会把内置插件复制/覆盖到 plugin_dir,理由是"内置插件是发行物的一部分"。
# 该前提已被废止:插件不再编入二进制、不进发行包、不自动安装,属于运维自有资产。
# 部署脚本只负责适配器(协议转换,随内核发布)与二进制/配置,绝不创建、覆盖或
# 删除 plugin_dir 里的任何文件。
# ---------- 重启服务 ----------
restart_service() {
log "重启服务(中断 <500ms)"
systemctl restart llmsproxy.service || fail "systemd restart 失败"
log "OK llmsproxy.service restarted"
systemctl restart modelrouter.service || fail "systemd restart 失败"
log "OK modelrouter.service restarted"
}
# ---------- 健康检查 ----------
@ -486,7 +481,7 @@ healthcheck() {
(( attempt++ ))
done
warn "健康检查未通过,请检查服务状态: systemctl status llmsproxy.service"
warn "健康检查未通过,请检查服务状态: systemctl status modelrouter.service"
return 1
}
@ -522,7 +517,7 @@ rollback() {
if (( restored_any == 0 )); then
err "无任何回滚点,无法自动恢复"
err "请手动检查: systemctl status llmsproxy.service && journalctl -u llmsproxy -n 50"
err "请手动检查: systemctl status modelrouter.service && journalctl -u modelrouter -n 50"
return 1
fi
@ -536,9 +531,9 @@ rollback() {
fi
log "重启服务(旧版本)"
systemctl restart llmsproxy.service || {
systemctl restart modelrouter.service || {
err "重启失败,服务可能完全宕机"
err "请手动检查: systemctl status llmsproxy.service"
err "请手动检查: systemctl status modelrouter.service"
return 1
}
@ -573,7 +568,7 @@ print_summary() {
# 于是坏配置在服务仍然健康时就被拦下,根本不会触发重启。
main() {
parse_args "$@"
log "═══ llmsproxy 原子部署开始 ═══"
log "═══ modelrouter 原子部署开始 ═══"
precheck
# 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开),
# 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经
@ -581,7 +576,6 @@ main() {
verify_master_key
build
sync_adapters
sync_plugins
backup_old_state
atomic_replace_binary
atomic_replace_config

61
docs/adapters.md Normal file
View File

@ -0,0 +1,61 @@
# 适配器协议
> 本文档由 README 拆分而来:Lua 适配器协议与内置适配器清单。
> 编写适配器的完整参考见 [lua-adapters.md](lua-adapters.md)。
> 返回 [README](../README.md)。
## Lua 适配器协议
完整 API 见 **[Lua 适配器 API 文档](docs/lua-adapters.md)**([English](docs/lua-adapters-en.md))。
每个适配器是一个返回 table 的 Lua 脚本(`internal/lua/adapters/<name>.lua`),
加载时可被 `adapter_dir` 下的同名脚本覆盖——**改 Lua 脚本无需重编译**,
重启即生效;通过 WebUI 上传的适配器与在线编辑的源配置则即时生效。
```lua
return {
name = "mysrc",
version = "1.0.0",
endpoint = "/chat/completions", -- 上游路径(可被 source.endpoint 覆盖)
headers = { ["X-Static"] = "v" }, -- 静态默认请求头(build_headers 缺省时使用)
-- 请求转换:把 OpenAI 格式 req 转成上游原生格式,返回字符串
transform_request = function(raw_json) ... end,
-- 响应转换:把上游原生响应转成统一格式字符串
-- { content, reasoning_content, finish_reason, token_usage{...}, tool_calls[{...}] }
transform_response = function(raw_json) ... end,
-- 流式分块转换:把上游 SSE data 转成 { content, done, ... },返回 "" 则跳过
transform_stream_chunk = function(raw_chunk) ... end,
-- [可选] 动态请求头/签名钩子
-- meta = { url, method, body, api_key, timestamp, source={ name, meta={...} } }
build_headers = function(meta) return { ["X-App-Sign"] = sign } end,
-- [可选] 出向序列化(契约 v2):上游不是 OpenAI 协议时接管写给客户端的字节。
-- 不声明则沿用内核的 OpenAI 兜底;声明即完全取代,内核不再混合字段。
serialize_response = function(result) return body_str end,
serialize_open_preamble = function(result) return bytes end,
serialize_chunk = function(delta) return bytes end,
serialize_close = function(result) return bytes end,
}
```
内置信号辅助:`hmac_sha256_hex(key, data)`、`sha256_hex(data)`、`base64_encode(s)`、
`tohex(s)`、`json.encode/decode`、`log(level, msg)`。
### 内置适配器
`openai` `deepseek` `anthropic` `gemini` `github` `groq` `mistral` `ollama` `qwen38` `sensenova`
`qwen38` `sensenova` `trae`。
`anthropic`/`gemini`/`ollama` 适配器内置多模态转换(`image_url` → 各自上游格式);
推理开关由中立的 `thinking` 字段表达,`deepseek` 适配器把它映射为
`thinking.type = "disabled"`。
适配器可用 `build_headers(meta)` 注入签名头。示例见 `adapters/` 下任一脚本的
`build_headers` 实现;云端校验调用方 app 时需要按
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。

View File

@ -1,540 +0,0 @@
# channels 插件与参考插件的三线对齐表
参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆
`/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于
`vendor/channel-pack/src/`(144 个 TS 文件)。
命名规律(照它逐厂商对齐的三条线):
| 后缀 | 职责 | 我们要对齐的东西 |
|---|---|---|
| `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 |
| `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 |
| `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 |
## 一、登录(line ①)
| 平台 | 参考流程 | 我们的 flow | 状态 |
|---|---|---|---|
| trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) |
| minimax | device-poll | device-poll | ✅ |
| cline | device-poll | device-poll | ✅ |
| buddy | state-poll(**无回调**) | state-poll | ✅ |
| gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) |
| lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) |
| opencode | paste | paste | ✅(参考本身即粘贴凭据) |
| **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) |
| qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) |
| zcode | init-poll | init-poll | ✅ |
| loomy | 微信扫码 poll | wechat-poll | ✅ |
| raccoon | 二维码 poll | qr-poll | ✅ |
### codearts 登录(本轮修复的重点)
此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts`
默认走**新式 IAM OAuth**:
1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝),
路径 `/oauth/callback`
2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项:
`theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、
`code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal
**回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、
`plugin-name=snap_AIIDE`、`plugin-version=5.2.0`;
**不得**含 `auth_callback_url`(会被判异常并回退)
3. 回调收到 `code` 后,带 **DPoP 证明**向
`https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换
`credentials{access_key_id, secret_access_key, security_token, expiration}`
+ `refresh_token`
4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk`
DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、
`payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER
——必须转换格式,否则服务端验签失败而字符串看起来完全正常。
终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`;
**不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、
网关抖动都会触发,当终态会把完好账号一步标死)。
旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询)
保留作回退。
## 二、模型获取(line ②)
远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。
本轮把**兜底表**逐平台补成参考全量:
| 平台 | 参考来源 | 兜底数 | 状态 |
|---|---|---|---|
| trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) |
| minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ |
| cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ |
| buddy | (无独立表) | 7 | ✅ |
| gemini | — | 2 | ✅ |
| lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ |
| opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) |
| codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 |
| qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) |
| zcode | `GET /api/v1/client/configs` | 2 | ✅ |
| loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ |
| raccoon | 远端 + 兜底 | 6 | ✅ |
**trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` /
`explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。
`model_list` 已按此过滤(`models_hidden`)。
**opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。
此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。
### codearts 模型获取(未闭环)
参考 `models.ts` 从两个端点拉:
- `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`)
→ `result.models`,这是 **benefit(免费额度)模型**权威来源
- `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型
两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId`
(去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带
`maas_type: benefit`,不带则 404 `model is not registered`)。
**这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。
## 三、接入模型服务(line ③)
**本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的
chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正:
| 平台 | 正确 chat 端点 | 修正前 |
|---|---|---|
| trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` |
| cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` |
| lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` |
| qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` |
| buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` |
| codearts | `…/api/v2/chat/completions` | `…/chat/completions` |
| opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` |
| raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` |
| loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` |
| zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` |
| gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) |
### ✅ 已闭环:三个私有协议平台改用**插件自装适配器**
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
`LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。
内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用:
| 平台 | 自装适配器 | 解决的协议差异 |
|---|---|---|
| codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) |
| trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 |
| gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 |
命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是
**运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)——
判据里专门断言内置 `trae` 未被顶掉。
**新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己
解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用
`credentialOf` 取 `access_token`,于是 codearts 凭据(只有
`access_key_id`/`secret_access_key`/`security_token`)取不到 token →
**静默 return、不出源**。现已按 `plat.credential_shape` 区分。
**另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败
(权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。
现补失败日志(区分内核 err 与 `json.ok==false`)。
### 仍未闭环:gemini 的 sigstore(明确不做)
参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。
我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒,
适配器如实透传错误。理由与结论已记在此处,避免反复讨论。
## 四、明确不做的(附理由)
| 参考模块 | 行数 | 为什么不做 |
|---|---|---|
| `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ |
| `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 |
| `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 |
| `captcha-*.ts` | 1223 | 同上 |
## 五、本机实测踩到的坑(都已修)
1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。
改用 `od -An -tx1`。
2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用
`openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里
**公钥的 y**,不是 `d`。
3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` /
`b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块
**之前**(本文件累计踩过 6 次)。
4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个
参数才是 minPort。
5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报
invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。
---
# 为什么参考插件是"正常"的:架构差异(2026-10-13 查证)
用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**,
不是某一个端点的笔误。
## 参考的架构:一个平台 = 一个适配器类
参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。
每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事:
| 文件 | 类 | 干什么 |
|---|---|---|
| `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) |
| `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 |
| `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) |
| `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 |
| `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 |
注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)`
(`llm-adapter.ts:2080`)。
**但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)——
`serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、
`httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。
12 个 adapter 里有 8 个 `import from './openai-compat'`。
即:**公共协议逻辑一份,平台差异各自一个类**。
## 我们的架构:内核是 proxy,插件只注册源
- 适配器是 Lua 文件(12 个),通过 4 个钩子参与:
`transform_request` / `transform_response` / `transform_stream_chunk` /
`transform_error`,外加 `build_headers`(动态签名,见下)。
- 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。
- 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台
注册为上游源,`source.adapter` 引用适配器名。
## 关键发现:机制早就有,但 channels 插件从没用过
内核**完整实现了**插件自装适配器:
```
internal/lua/plugin_adapter_bridge.go
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error)
registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove
```
- 有归属校验(`adapterOwnership`:只能改自己装的)
- 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒)
- 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS)
**但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。**
channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。
这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。
## 我们还缺的一层:共享协议模块
参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明
「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与
`anthropic.lua` 里**各复制了一份**(各 6 处引用)。
所以正确做法是两层:
1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类),
或内核在加载时注入 —— 消掉复制粘贴
2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、
codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai`
### 所需的适配器能力(crypto + 出站 + 流式)
已核实内核提供:
- `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals)
- `build_headers(meta)` 收 `{url, method, body, api_key, timestamp,
client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它
(与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出)
- `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`)
- 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名
### 未验证的风险
- 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名)
- 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls)
这两条是下一步落地前必须先探的。
---
# 插件自装适配器:机制与判据(2026-10-13)
## 机制(内核早已具备,本轮才开始使用)
```
internal/lua/plugin_adapter_bridge.go
InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决)
registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove
```
注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期
就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。
## 三条能力(实测,不是推断)
`internal/lua/plugin_adapter_capability_test.go` 锁死:
| 能力 | 结论 | 为什么关键 |
|---|---|---|
| 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) |
| 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 |
| 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 |
## 踩过的坑(都是"静默失败"类,值得记)
1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误**
(`'<name>' expected near 'function'`),必须 `body["function"]`。
插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。
2. **Lua 长字符串里不能有字面 NUL 字节** —— 报
`unfinished long string near '<eof>'`,且**报在文件末尾**,完全指不到现场。
3. **本 Lua 构建不支持按位异或 `~`**(`unexpected symbol near '~'`)。
4. **装载失败只回"源码无法编译"、不给行号** —— 三个自装适配器现统一加
**本地预编译护栏**(先 `load` 一次拿行号)。上面三个坑都是靠它定位的。
5. **抽公共代码要抽最全的实现**。前导重构时我先抽了 `transform_error` 的
最小版(只认 `{error:{message}}`),打挂 6 个既有测试 —— 最小版丢掉了
HTML 错误页、裸文本、扁平 `{code,message}`、FastAPI `{detail}`。
抽最小实现是**静默降级**:不报错,只是少认几种错误体。
6. **判据必须能看见真实函数的返回值**。我一度用 `kernelTestCore` 裸加载插件
(没授 `add_source` 权限)测源注册,源一直不出;直到把内核原始返回
(`kernel action add_source is not granted to plugin channels`)带出来才
定位。**黑盒断言只在失败时告诉你"不对",不告诉你"为什么"**。
## 判据清单
| 文件 | 锁死什么 |
|---|---|
| `internal/lua/plugin_adapter_capability_test.go` | 三条能力(凭据/流式状态/加密原语) |
| `internal/lua/adapter_prelude_test.go` | 前导注入 + 语义值正确 + 旧适配器行为不变 |
| `internal/lua/adapter_prelude_equivalence_test.go` | 重构前后逐字节等价(含反向断言) |
| `internal/lua/channels_trae_solo_test.go` | SOLO 7 条规则 + 三处反向断言 |
| `internal/lua/channels_gemini_adapter_test.go` | 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 |
| `internal/core/channels_codearts_adapter_test.go` | 装机/归属 + **Go stdlib 独立复算 HMAC 验签** |
| `internal/core/channels_private_adapter_wiring_test.go` | 三平台的**源真的引用了**自装适配器 |
---
# 生产实测:不可用的真相(2026-10-14)
## 症状与根因
用户报"完全不可用"。实测(`/api/plugins/http/channels/account/list` 逐平台):
| 平台 | 账号数 | **有凭据的** |
|---|---|---|
| buddy | 2 | **1** |
| trae | 3 | 0 |
| minimax | 1 | 0 |
| lobsterai | 1 | 0 |
| codearts | 5 | 0 |
| 其余 7 平台 | 0 | 0 |
**13 个账号里只有 1 个真有凭据**,其余全是**占位壳子**(`has_credential: false`)。
占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的
行。此前的判断只看「12 平台 0 个 manual」,那是**错的判据** —— manual 是
flow 类型,不是"登录成功"。
## 排除项(都实测过,不是猜)
| 检查 | 结果 |
|---|---|
| 服务状态 | `active`,`NRestarts=0` |
| 部署一致性 | 4 个插件 sha256 与仓库**全部一致**;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 |
| **推理链路** | `POST /v1/chat/completions` 实测返回 `"OK"`(走 buddy 源、deepseek-v4.1-flash)—— **网关本身完全可用** |
| 登录机制 | minimax 真实返回 `user_code=T49S-RC7G` + 验证地址;trae 起了服务端浏览器 + 回环回调 |
| 设备码轮询 | 5 次全部 `pending`(未授权时**不误判**为失败或成功 —— 这是正确行为) |
## 结论:不是代码坏了,是**授权没完成**
剩下的 12 个平台都需要**人工动作**才能走完登录:
- **device-poll**(minimax / cline / qoder):把 `user_code` 拿到
`account.minimax.cn/oauth-authorize` 之类的**官方页面**输入
- **callback**(trae / gemini / lobsterai):在 **VNC 窗口**里点授权
(回环回调只能由本机解析,远程浏览器永远接不住)
- **qr-poll / wechat-poll**(raccoon / loomy):扫码
- **init-poll**(zcode):授权页 + 可能的验证码
这些**按设计就必须人工**,assistant 无法独立端到端完成。
## B 类:实测结论
| 项 | 行数 | 结论 |
|---|---|---|
| qoder-wasm + envelope | 706 + 226 | **二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。没有 `qoder-auth-wasm.wasm` 无法实现;拿到才有意义 |
| zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且**登录本身不需要它** —— captcha 只用于**签到/领取**环节(`zcode-auth.ts:22-23`:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 |
| captcha-pool | 280 | 同上,服务于领取环节的验证码供给 |
**修正判断**:B 类不是"登录闭环的拦路虎"。zcode 的登录(`/api/v1/oauth/cli/init`
→ poll)不需要验证码;captcha 只在**自动领取**时出现。所以 B 类的真实价值是
"让领取全自动",而不是"让平台可用"。
---
# 真实数据事故:测试覆盖了生产账号池(2026-10-14)
## 症状
用户报「完全不可用」。逐平台 `account/list` 后发现:**13 个账号里只有 1 个有凭据**,
其余是占位壳子;再查发现**账号池被清空了**(`pool` 全为空 `{}`),
而保险库 `.secrets/channels.json` 里的凭据**还在**。
症状正是「账号凭空消失」。
## 根因(实测输出,非推断)
channels 插件用 `storeDir()` 决定存储位置,它有多级兜底:
```
env LLMSPROXY_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码
```
测试台没设 `LLMSPROXY_PLUGIN_DIR`,**后两级直接命中生产**:
```
测试插件目录 = /tmp/TestXXX/002/plugins
storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出
```
于是测试加载真 `channels.lua` 时**读的是生产存储、写的也是生产存储**。
一次全量 `go test ./internal/lua` 就把
`.channels-store.json` 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。
只有 `internal/lua` 复现,`internal/core` 不复现 —— 因为 core 的测试台
(`kernelTestCore`)设了 `LLMSPROXY_PLUGIN_DIR`。**这是隔离做得不一致**的代价。
同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。
## 为什么 `DisableStatePersistence` 不够
第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule`
时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`),
真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并
`logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** ——
测试结束后它仍可能落盘,此时环境变量已还原。
## 修法(两层,第二层才是根治)
1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜):
新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`),
`storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** ——
实测:撤掉 env 后 `storeDir` 仍落在临时目录。
2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。
## 判据 + 变异验证
`internal/lua/store_dir_isolation_test.go`:
`storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。
**变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」;
恢复 → 变绿。
全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。
## ⚠️ 修复过程中我自己踩的坑
恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的
`credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被
**二次加密**(1971 → 2965 字符),该账号的推理开始返回空
(`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。
教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。
要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。
---
# 逐模块对照的当前状态(2026-10-14 更新)
穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定:
✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做
## 本轮(第 2 批)新补的
| 模块 | 参考规模 | 补了什么 / 为什么 |
|---|---|---|
| `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) |
| `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 |
| `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 |
| `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 |
| `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) |
| `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 |
| `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 |
另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带
error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。
## 仍未实现(按影响面排序)
| 模块 | 行数 | 为什么不做的理由 / 阻塞 |
|---|---|---|
| `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" |
| `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 |
| `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 |
| `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) |
| `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) |
| `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 |
| `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** |
| `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 |
| `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 |
| `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) |
| `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) |
| `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 |
## 一句话结论
**平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台
自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。
**未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动
(captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。
其余是宿主 UI 特性或前提不存在的模块。
---
# 第三批(2026-10-14 晚)—— 逐模块对照的**新发现**
## ★ 本轮新补的三个真缺陷
### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`)
活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次
⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。
**修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**,
记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。
### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`)
`account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true`
⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。
这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。
### ③ `isFree` 与声明自相矛盾(`cline-models.ts`)
cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。
★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`,
opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。
⇒ 加 `free_source` 由**平台声明**权威来源。
## 插件自装适配器(第 3、4 个)
| 适配器 | 解决的问题 |
|---|---|
| `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) |
| `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) |
现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。
## 对照后判定「不适用」的(附理由,不是"没做")
| 模块 | 为什么不需要 |
|---|---|
| `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) |
| `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 |
| `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels-<plat>-<acct>`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 |
| `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 |
| `loop-recovery` | 前提不存在(我们的内核没有循环守卫) |
| `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) |
| `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) |
| `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 |
## 运行环境的一条教训(不是代码缺陷)
全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、
看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建
缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。

183
docs/configuration.md Normal file
View File

@ -0,0 +1,183 @@
# 配置
> 本文档由 README 拆分而来:网关配置、模型自动探测、多密钥。
> 返回 [README](../README.md)。
## 配置文件
首次运行在 `-config` 路径自动生成默认配置(默认 `./config.yaml`),核心字段:
```yaml
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
gateway_keys: [sk-gw-<随机>] # 初始 admin 密钥(每次安装随机生成,仅首启写入运行时存储用)
default_model: AUTO # model 无法路由时自动按优先级链选源
adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读
runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件
sources:
- name: deepseek
base_url: https://api.deepseek.com
api_key: sk-... # 也可用 api_key_env: SOME_ENV 引用环境变量(不落盘明文)
adapter: deepseek
max_concurrent: 8
models:
- id: deepseek-v4-flash
priority: 100 # YAML 源首次启动会 seed 进 AUTO 链的初值
kind: chat
- id: deepseek-v4-pro
priority: 60
kind: chat
# 静态请求头(比适配器默认优先)
headers: { X-Tenant: prod }
# 传给 Lua build_headers 的透传元数据
meta: { app_id: x, app_secret: y }
temperature: 0.7
max_tokens: 4096
timeout: 120s # 请求超时,默认 120s
```
## 自动探测模型列表
不想手抄模型 id 时,只填 URL 和 key,让网关去扫:
```bash
# 只探测,不写任何东西
curl -X POST http://127.0.0.1:8080/api/sources/discover \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…"}'
# → {"ok":true,"base_url":"…","endpoint":"","models":["gpt-x",…],
# "image_models":["gpt-image-2"],"models_path":"/models","latency_ms":1387,…}
# 探测后直接落库
curl -X POST http://127.0.0.1:8080/api/sources/discover/apply \
-H "Authorization: Bearer sk-gw-…" -H "Content-Type: application/json" \
-d '{"base_url":"https://api.vendor.com/v1","api_key":"sk-…","name":"vendor"}'
```
WebUI 源编辑框里也有「自动探测模型」按钮:填好 URL 和 key 点一下,列表自动
填好,仍需点保存才生效。
几点值得知道:
- **URL 形态不用猜。** 厂商对「`/v1` 放 base 还是放 path」没有共识,探测会
依次尝试若干候选并返回答对的那个,同时给出归一化后的 `base_url` +
`endpoint`(`endpoint` 平时留空,因为 openai adapter 自带 `/chat/completions`)。
- **`image_endpoint` 是另一套规则。** 网关的图像默认路径是**绝对**的
`/v1/images/generations`,而 chat 默认是相对的。所以 `base_url` 带 `/v1` 时
必须显式给 `image_endpoint: /images/generations`,否则图像请求 404。
探测会替你填好。
- **图像模型只看上游自己声明的 modality**,绝不从模型名猜。`gpt-image-2`
这类 id 也出现在同时提供对话服务的目录上,猜错会让 AUTO 链选中一个只支持
图像生成的模型,其后每一层都被它拖垮。
- **不会逐模型探活。** 34 个模型的目录跑一轮要花钱且耗时数分钟,`/models`
是足够便宜的信号;模型是否真能应答留给正常请求去发现。
- **对已存在的源,必须显式 `replace_models: true`** 才会覆盖模型列表。重新
探测顺手抹掉为 AUTO 链手工调过的 priority,是最难排查的一类数据丢失。
- **`api_key` 留空表示沿用已存值**,不会把凭据清空。
`config.yaml`、`runtime.json`、`master.key`、`adapters/` 均不提交版本库(见
`.gitignore`),请随备份带走——`master.key` 丢失后运行时密文无法解密。
运行文件生成在 `-config` 所在目录:`adapter_dir` / `runtime_file` 自动指向
配置文件旁边的 `adapters/` 与 `runtime.json`,从任意工作目录启动都可用。
运行时文件(`runtime_file`)中的敏感字段自动加密:
- 加密算法 AES-256-GCM,格式 `enc:v1:<base64>`。
- 主密钥来源:环境变量 `MODELROUTER_MASTER_KEY`(64 位 hex);否则读取
`runtime_file` 同目录的 `master.key`;都不存在时首次启动自动生成 `master.key`(0600)。
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
## 网关密钥(多密钥)
网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的
`keys` 字段(加密存储):
- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin
key,之后不再参与鉴权管理。
- **重要:首次启动后请在 WebUI「密钥」页更换管理员密钥**——初始密钥明文写在
`config.yaml` 里(每次安装随机生成),继续使用存在被盗风险;用新密钥登录后删除初始密钥。
- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或
`user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 +
重置周期)。
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
- 删除密钥即从运行时存储移除,立即失效。
### 密钥用量配额
**配额按模型单独设置**:每个密钥的 `models[]` 里,每一条模型范围各自带一份
token 配额与请求数配额。一个模型用满只影响该模型,同一密钥的其它模型照常工作。
```yaml
keys:
- key: sk-gw-<hex>
role: user
name: agent-alice
models:
- model: deepseek-v4-flash
token_quota: 1000000 # 本周期内该 key 用这个模型的 token 预算
req_quota: 20000 # 本周期内的请求次数
period: nhour # "" | hour | week | month | nhour
hours: 6 # 仅 nhour
- model: AUTO
token_quota: 5000000 # AUTO 也是一条独立配额
period: day # ← 这种写法会被拒绝(词表只有 hour/week/month/nhour)
- model: kimi-k3 # 未列配额 = 无限
```
- **没有「整钥总配额」**:这是刻意的设计。整钥总额会让「把 A 模型的额度挪给
B 模型」变成一次全局重分配;按模型独立计费则每个模型各自可控,运维可以
看出哪个模型吃掉了预算。
- `period` 词表:空 = 永不过期(累计总量),`hour` / `week` / `month` = 固定窗口,
`nhour` + `hours` = 自定义小时数。**拼错的周期在写入时就被拒**,不会静默变成
永不过期。
- 配额严格按密钥隔离,且**同一密钥内按模型隔离**:A 密钥用满 `m1` 不会消耗
B 密钥的额度,同一密钥的 `m2` 也不受影响。
- 配额统计含聊天、流式、生图,跨重启从审计日志回放(保留 40 天,覆盖最长的
month 窗口)。
- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),消息里点名是哪个
模型用满了,客户端可等窗口重置后自动恢复;模型越权才是 403。
**admin 密钥永不受配额限制**(其 scope 上的配额也不强制),避免把管理员
锁在门外。
- 窗口用量按整点小时分桶统计,实际释放比配置窗口最多晚 1 小时(配额宁可晚释放
也不超发)。
- `PUT /api/keys/{key}` 提交 `models` 即同时提交它们的配额(配额就是 scope 的一部分,
不存在会与模型列表脱节的第二份预算)。显式 `0` = 解除该模型的限制。
#### 配额拒绝 vs 容量拒绝:两种「拒绝」含义不同
配额在**入口**检查,不占用任何上游槽位,因此是廉价拒绝;容量不足则要走
`busyWait` 有界等待后才返回 503。实测(容量 8、0.6s/请求、100 并发):
| 情况 | 状态码 | 延迟 | 是否打到上游 |
|---|---|---|---|
| 配额用尽(`req_quota` 50 → 100 并发) | `429 rate_limit_exceeded` + `Retry-After` | **19–21ms** | 否 |
| 容量打满(同一拓扑,未限配额) | `503 upstream_error` | ~2.6s(`busyWait`) | 超出部分否 |
客户端因此可以区分「等窗口重置」与「等上游腾出容量」:前者只需等待,后者
通常该降并发或换源。
#### 配额的开销
配额桶按 (密钥, 模型, 整点小时) 分桶保留 40 天,实测(AMD 7840HS):
- 窗口查询按窗口长度而非保留总量扫描:1h 窗口 49ns、24h 窗口 55ns、
30d 窗口 3.9μs(此前全扫保留总量,960 桶时 5.9μs)。
- 记录一条请求:283ns、3 分配(与引入配额前相同,分配来自 ring buffer)。
- 内存:生产形态(7 密钥 × 8 模型 × 2 源 × 满 40 天 retention)约 **3.7MB**。
按源 pin 的 `source::model` 桶**惰性创建**——只有当某条配额真的 pin 了
某个源时才维护,否则每条记录多写一份桶,在 20 密钥 × 8 模型 × 3 源下会
多占 18MB。
代价:配置 pinned 配额之前的历史用量无法事后按源拆分,所以 **pinned 配额的
首个窗口可能少算**。
```bash
# 配额耗尽时客户端看到(点名了具体模型)
HTTP/1.1 429 Too Many Requests
Retry-After: 2100
{"error":{"type":"rate_limit_exceeded","message":"token quota exceeded for \"deepseek-v4-flash\" (5000000/5000000)"}}
```

142
docs/deployment.md Normal file
View File

@ -0,0 +1,142 @@
# 部署与迁移
> 本文档面向运维:单机 systemd 部署、发行包安装、目录与权限、从 `llmsproxy`
> 改名后的迁移与回滚。
> 返回 [README](../README.md)。
## 1. 单机部署(推荐)
仓库根目录的 `deploy.sh` 负责:构建 → 预检配置 → 备份 → 原子替换二进制 →
重启服务 → 后置校验。它**不**修改源码,也**不**假设全新机器。
```bash
sudo ./deploy.sh # 默认读取仓库内 config.yaml
sudo ./deploy.sh --config other.yaml # 投放并部署另一份配置
```
部署目标(改名后):
> 两种布局并存,区别只在于二进制位置:**包安装**(deb/rpm)放 `/usr/bin`,
> unit 自带;**源码部署**(`deploy.sh`)放 `/usr/local/bin`,需要让 unit 的
> `ExecStart` 指向它。`deploy.sh` 会校验这一点,写错时直接中止而不是留下
> 一个 203/EXEC 的服务。
| 项 | 路径(源码部署 / 包安装) |
|---|---|
| 二进制 | `/usr/local/bin/modelrouter` / `/usr/bin/modelrouter` |
| 配置 | `/etc/modelrouter/config.yaml` |
| 适配器 | `/etc/modelrouter/adapters/` |
| 插件(可选) | `/etc/modelrouter/plugins/`(需在配置里设 `plugin_dir`) |
| 主密钥 | `/etc/modelrouter/master.key`(0600) |
| systemd unit | `/etc/systemd/system/modelrouter.service` |
`deploy.sh` 会在以下情况**中止**而不是"看起来成功":
- 部署后二进制起不来、或新配置 `-check` 不通过;
- 凭据无法解封(`master.key` 丢失 / 与配置不匹配);
- 检测到改名前的 `/etc/llmsproxy`,但新配置尚未建立(否则会部署出一个
没有 key、没有源的空实例,所有请求都被拒)。
## 2. 发行包安装
三个产物由 `packaging/core-dist.sh` 生成到 `cmd/build/dist/`:
```text
modelrouter_<ver>_amd64.deb
modelrouter-<ver>.x86_64.rpm
modelrouter-<ver>-linux-amd64.tar.gz
```
deb / rpm 安装后:
```bash
sudo systemctl enable --now modelrouter
sudo journalctl -u modelrouter -f
```
tar 包内只有:`modelrouter`、`config.example.yaml`、`modelrouter.service`、
`migrate-to-modelrouter.sh`、`adapters/`。
**发行包不含业务插件**(仓库只保留 `billing` / `usage` 两个与厂商无关的示例)。
它们保留在仓库中作为可选扩展,需要时手工放入 `plugin_dir`,网关不会自动安装、
覆盖或删除。这是有意的:插件属于运维资产,生命周期不与内核绑定。
首次启动会在 `/etc/modelrouter/config.yaml` 生成一份**随机 admin key** 的配置,
key 打印在启动日志里。发行包不预置任何可用 key(历史上曾因此泄露过一个真实的
`sk-gw-local-0001`)。
## 3. 目录与权限
- `/etc/modelrouter` 是唯一的读写根(systemd unit 用 `ProtectSystem=strict` +
`ReadWritePaths=/etc/modelrouter` 固化这一点)。
- `master.key` 必须 0600。当前 unit 以 root 运行:降权到普通用户需要先把
`master.key` 交给该用户并统一目录属主,否则服务会"带着明文落盘继续跑",
失败只留一行日志——这是独立变更,不要和加固混在一起。
- `plugin_dir` 由运维维护;插件自带的状态文件也在其中。
## 4. 从 llmsproxy 迁移
二进制、配置目录、服务名、环境变量已统一为 `modelrouter`:
| 旧 | 新 |
|---|---|
| `/usr/local/bin/llmsproxy` | `/usr/local/bin/modelrouter` |
| `/etc/llmsproxy/` | `/etc/modelrouter/` |
| `llmsproxy.service` | `modelrouter.service` |
| `LLMSPROXY_PLUGIN_DIR` | `MODELROUTER_PLUGIN_DIR` |
| `LLMSPROXY_XDISPLAY_BASE` | `MODELROUTER_XDISPLAY_BASE` |
| `LLMS_PROXY_MASTER_KEY` | `MODELROUTER_MASTER_KEY` |
迁移脚本**默认 dry-run**,复制而非移动,旧目录永不删除:
```bash
sudo ./packaging/migrate-to-modelrouter.sh # 预演(逐条打印将做什么)
sudo ./packaging/migrate-to-modelrouter.sh --apply # 执行
```
它做五件事:复制状态目录并改写配置里的旧绝对路径、复核 `master.key` 0600、
安装新二进制(旧文件保留)、由旧 unit 生成新 unit(改写 ExecStart /
WorkingDirectory / ReadWritePaths)、复制 `/usr/share` 资源;最后跑
`modelrouter -check` 并 `systemctl daemon-reload`。
确认新服务可用后,再手工清理旧布局(脚本不会删):
```bash
sudo systemctl disable --now llmsproxy.service
sudo rm -f /etc/systemd/system/llmsproxy.service
sudo rm -rf /usr/local/bin/llmsproxy /etc/llmsproxy /usr/share/llmsproxy
```
回滚(不需要旧文件被删):
```bash
sudo ./packaging/migrate-to-modelrouter.sh --rollback --apply
```
**读取侧兼容**:旧配置路径与旧环境变量仍被接受,所以"先换二进制、后改 unit"
这种顺序不会立刻失效。但写入永远落在新路径,迁移完成后不要两台布局混用。
## 5. 部署后验证清单
```bash
# 1) 进程与监听
systemctl status modelrouter --no-pager
ss -ltnp | grep modelrouter
# 2) 鉴权生效(无 key 必须 401)
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/v1/models
# 3) 带 key 能列出模型
curl -s -H "Authorization: Bearer $KEY" http://127.0.0.1:8080/v1/models | head -c 400
# 4) 真实请求走通(证明 LuaJIT 适配器路径未被打断)
curl -s -H "Authorization: Bearer $KEY" \
-d '{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}' \
http://127.0.0.1:8080/v1/chat/completions | head -c 400
# 5) 仓库文件与生产文件一致(部署脚本的收尾检查)
sha256sum /usr/local/bin/modelrouter build/modelrouter
```
第 4 步失败但第 3 步成功,通常是适配器或上游凭据问题,不是部署问题:
查看 `journalctl -u modelrouter -n 100`。

34
docs/development.md Normal file
View File

@ -0,0 +1,34 @@
# 开发与目录结构
> 本文档由 README 拆分而来:目录职责、开发协作、测试。
> 返回 [README](../README.md)。
## 目录
```
cmd/modelrouter # 入口(纯 Go 服务端)
cmd/gui # Electron 桌面 GUI(内嵌核心 + 托盘 + 打包)
internal/config # YAML 配置加载/校验
internal/lua # LuaJIT VM + worker 池 + AdapterCache + 内置适配器 (embed)
internal/provider # Provider(HTTP) + Registry(路由)
internal/gateway # OpenAI 兼容 HTTP 服务 + 鉴权 + SDK/流式 + WebUI
internal/types # 统一格式 & OpenAI wire 类型
```
## 开发协作
- **Git 分支工作流**:主分支 / 特性分支 / 发布分支的规范见
**[docs/git-workflow.md](docs/git-workflow.md)**([English](docs/git-workflow-en.md))。
主线:`main` 永可部署 → 特性合入 `main` → 切 `release/vX.Y.Z` 打 tag 发布 →
hotfix 提交发布分支并 cherry-pick 回 `main`。
- **Lua 适配器协议**:见 **[docs/lua-adapters.md](docs/lua-adapters.md)**
([English](docs/lua-adapters-en.md))。
## 测试
```bash
go test -tags luajit ./...
```
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
模型路由、多模态透传与 thinking 预算。

48
docs/features.md Normal file
View File

@ -0,0 +1,48 @@
# 实现与完整特性
> 本文档由 README 拆分而来:架构来源与完整特性清单(README 只保留摘要)。
> 返回 [README](../README.md)。
## 实现
统一 OpenAI 兼容网关:把多个上游 LLM 源(DeepSeek、Qijiar、OpenAI、Anthropic、Gemini、
Groq、Mistral、Ollama、KimiCode…)通过 **Lua 适配器** 做协议转换,对内网暴露
一个标准 `OpenAI Chat Completions` 接口(`/v1/chat/completions` + `/v1/models`),
支持单次结算、SSE 流式、**AUTO 模型路由**与**多模态**透传。
从上游多源 LLM 适配层
(`internal/agent/api/provider.go` + `internal/lua/adapters/*`)抽离而来并独立演进。
## 特性
- **多源**:一个进程内配置任意多个上游源,按请求的 `model` 自动路由。
- **AUTO 模式**:`default_model: AUTO` 时按优先级页保存的 AUTO 链档位逐档调度并发请求。
- **统一输出**:所有源对外都是 OpenAI 格式(含 `reasoning_content`、`tool_calls`、`usage`)。
- **生图**:`POST /v1/images/generations`,`kind: image` 的模型独立路由。
- **多模态**:`content` 数组(`image_url` 等)在多源间无损透传,Anthropic/Gemini/Ollama 自动转换。
- **LuaJIT VM**:基于 golua 绑定的 LuaJIT,每个适配器独立 VM + worker 池,安全并发。
池是 **弹性** 的:`Σ max_concurrent` 只是上限而非预分配,状态按需创建、空闲时自动回收,
因此空载网关几乎不持有 Lua 状态(启动 0 个)。
- 扩容步长由适配器最大并发决定:`clamp(ceil(max/8), 1, 8)`,且仅在**真实并发争用**
(所有现有状态均已占用)时批量预热,顺序流量始终只用 1 个状态。
- 缩容步长由当前连接数决定:`clamp(ceil(冗余/(1+占用)), 1, 冗余)`——无连接时一轮收到
常驻下限,忙时每轮只释放 1 个,保护热路径。WebUI「适配器」页展示实时池指标。
- **thinking 预算**:请求里的推理开关/预算归一为中立的 `thinking:{enabled,budget_tokens}`,
适配器映射到各自协议(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig`、
DeepSeek `thinking.type="disabled"`)。
- **Lua 适配协议**:每个源挂一个 `.lua` 适配器,完成 `transform_request` /
`transform_response` / `transform_stream_chunk` 双向转换,协议差异全在 Lua 层。
- **签名 / 请求头钩子**:适配器可定义 `build_headers(meta)`,在 Go 发 HTTP 前
动态注入/签名请求头——用于云端 API 校验调用方 app(如 KimiCode 只放行特定
agent)。提供 `hmac_sha256_hex` / `sha256_hex` / `base64_encode` 等签名辅助。
- **WebUI**:内置管理界面,可在线查看/新增/编辑上游源与模型、配置 AUTO 优先级链、
管理密钥模型范围,写入运行时文件持久化。
- **密钥加密存储**:运行时文件中的上游 `api_key`、自定义请求头值、网关 key 均以
AES-256-GCM 加密落盘(`master.key` 独立 0600,或 `MODELROUTER_MASTER_KEY`)。
- **实时源探测**:状态页探测各源可达性(`GET {base}/models`,失败回退最小请求),
不污染正常调度的退避状态,错误信息在 UI 可悬停查看。
- **鉴权**:网关自身用 `gateway_keys` 校验客户端 Bearer key;与上游各自的 key 相互独立。
- **流式**:SSE `chat.completion.chunk`,含角色首包与 `[DONE]` 收尾。
- **全链路审计**:HTTP 访问日志、登录/配置变更审计、请求记录 CSV 导出、密钥用量统计 CSV 导出
- **AES-256-GCM 加密存储**:运行时文件敏感字段加密落盘,master.key 0600 权限,支持环境变量注入主密钥

View File

@ -134,7 +134,7 @@ git push origin vX.Y.Z
make core-dist VERSION=X.Y.Z
# 5. create GitCode Release + upload
# https://gitcode.com/JianFeeeee/ModelRouter/releases
# https://atomgit.com/HeNanMU-EEA/ModelRouter/releases
# upload MUST use PUT --http1.1, otherwise the OBS callback fails and the
# file never lands in the release; replacing artifacts = delete the tag
# (release disappears with it) then recreate.

View File

@ -124,7 +124,7 @@ git push origin vX.Y.Z
make core-dist VERSION=X.Y.Z
# 5. 创建 GitCode Release + 上传安装包
# 见 https://gitcode.com/JianFeeeee/ModelRouter/releases
# 见 https://atomgit.com/HeNanMU-EEA/ModelRouter/releases
# 上传注意:必须 PUT --http1.1,否则 OBS 回调失败文件不进列表;
# 替换旧版附件 = 删 tag(release 随 tag 一起消失)后重建。
```

86
docs/gui.md Normal file
View File

@ -0,0 +1,86 @@
# 桌面 GUI(Electron)
> 本文档由 README 拆分而来:Headless 与 Desktop 两种产物、构建与运行。
> 返回 [README](../README.md)。
## 桌面 GUI(Electron)
可选独立产物,面向内网桌面端用户:**内嵌 ModelRouter 核心**(桌面壳形态),
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 WebUI
(状态/对话/密钥/优先级/源/适配器 六个页面,免登录)。服务器用户继续使用纯 Go 二进制。
### 两种安装包:Headless 与 Desktop
发布到 Release 的安装包分为两类,面向不同用户:
- **Headless(服务器版)**:纯核心二进制(无 Electron),配置驱动,适合服务器、
容器、systemd 等无人值守场景。发布包含 `deb` + `rpm`(含 systemd unit、内存调优
与示例配置)与 `tar.gz`(二进制 + `config.example.yaml` + `adapters/` + README),
Windows 为 `.zip`。
- **Desktop(桌面版)**:Electron GUI 安装包,内嵌核心,适合个人桌面日常使用。
### 选择 GUI 还是纯后端
| 场景 | 推荐 | 理由 |
| ------ | ------ | ------ |
| 服务器 / 内网网关 / 无人值守常驻 | **Headless**(单二进制) | 极轻量(二进制 ~8MB;RSS 随源数量,单源 ~10MB、16 源 ~32–35MB,见[内存占用](#内存占用实测与调优)),零依赖单进程,直接跑在 systemd/任意容器里,远程管理 |
| 个人桌面日常使用 / 多设备内网共享 | **Desktop**(GUI) | 免登录内嵌 WebUI、系统托盘一键启停、开机自启、静默后台,适合不懂命令行的使用者 |
| Windows 桌面 | **Desktop** | 纯后端在 Windows 上需自行注册服务,GUI 提供原生托盘/自启体验 |
| CI 一键出三平台安装包 | **Desktop 打包脚本** | `make gui-*` 系列出 deb/AppImage/NSIS,可进发行流水线 |
两者完全同源:GUI 内嵌的就是纯后端的同一份 `modelrouter` 核心(LuaJIT 版),
配置/适配器格式完全一致,可随时互换。
### 构建与运行
```bash
cd cmd/gui
npm install
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/modelrouter)
make gui-deb # 本机构建 deb(分享给其它 Linux 用户)
make gui-dist # 构建 deb + rpm + AppImage(linux,rpm 需系统 rpmbuild)
make core-dist # 构建核心 deb + rpm + tar.gz(服务器版,需 nfpm)
make gui-win # 构建 Windows nsis 安装包(本机 mingw + wine)
make gui-win-docker # 构建 Windows nsis 安装包(全 docker 自足,含 wine)
make gui-dist-dir # 仅解包产物(调试用,不产安装包)
```
产物在 `cmd/build/gui-dist/`:`ModelRouter-<ver>.AppImage`、
`modelrouter-gui_<ver>_amd64.deb`、`ModelRouter Setup <ver>.exe`(win);
核心包在 `cmd/build/dist/`:`modelrouter_<ver>_amd64.deb`、`modelrouter-<ver>.x86_64.rpm`。
rpm 需系统 `rpmbuild`。每次打包后都会跑 `packaging/verify-dist.sh` 产物体检
(大小下限门禁——1.3.0 曾因宿主 wine 损坏产出 264KB 的残缺 exe 却没有被拦住)。
发布时会把产物重命名为 `ModelRouter-Desktop-*`(GUI)与 `ModelRouter-Headless-*`(纯后端)
两类上传到 GitCode Release。
#### Windows 打包(docker 固化方案,推荐)
`make gui-win-docker` 一步完成:交叉编译核心 + NSIS 打包**全在 docker 内**,宿主机
**无需 mingw、无需 wine、无需 node**。镜像内置 wine(含 i386 运行时),修复了
此前宿主 wine 损坏(缺 syswow64 → `c0000135`)导致产出 264KB 残缺 exe 的问题。
- 构建镜像:`cmd/gui/docker/win-builder/Dockerfile`(`golang:1.25` + mingw-w64 +
node + wine32/wine64 + 预编译 LuaJIT windows 版 `lua51.dll` 与 import lib)。
LuaJIT 源码取自国内镜像,上游兜底;Go 依赖走 `https://goproxy.cn`。
- 一键发布:`cmd/gui/scripts/dist-win-docker.sh` —— 核心编译 + electron-builder
NSIS 全部在容器内执行(`USE_SYSTEM_WINE=true` 走镜像内的 wine);electron-builder
的 ~400MB 工具链缓存挂到 docker volume,二次构建增量复用。
- 首次运行自动构建 `modelrouter/win-builder` 镜像(约 5–10 分钟);已存在则直接复用。
- 打包完成后自动跑 `packaging/verify-dist.sh` 门禁:exe 低于 5MB / deb·rpm·7z 低于
10MB 即失败——残缺安装包不可能再溜进 Release。
```bash
# 全 Windows 发布(核心重编 + NSIS):
make gui-win-docker
```
### 特性
- **内嵌核心**:自动拉起捆绑的 `modelrouter`(LuaJIT 版),配置/密钥/adapter 存于
用户数据目录 `<userData>/profile/`,首次运行自动生成随机 admin key 写入 `keys`
(非 seed)并自注入,WebUI 免登录、不会提示"更换初始密钥"。端口可在设置中更改(默认 8787)。
- **系统托盘**:内核状态点、开机自启/静默启动开关、启停/重启内核、退出。
- **静默启动**:`--silent` 参数或设置项,启动仅驻托盘不弹窗;首次运行始终显示窗口。
- **开机自启**:Windows/macOS 用 `setLoginItemSettings`;Linux 写
`~/.config/autostart/modelrouter-gui.desktop`,静默开启时自动附加 `--silent`。

60
docs/performance.md Normal file
View File

@ -0,0 +1,60 @@
# 性能与内存
> 本文档由 README 拆分而来:实测内存占用、调优手段、thinking 预算。
> 返回 [README](../README.md)。
## 内存占用(实测与调优)
内存不是一个定数:它随**配置的源数量**增长(每个源一个 `http.Transport` 与连接池、
一组健康状态),与运行时长无关。本机实测(Linux x86_64,12 核):
| 部署形态 | 启动 RSS | 稳态 RSS |
|---|---|---|
| 1 源 / 1 适配器(最小配置) | ~4 MB | ~10 MB |
| 1 源 + 29 MB 历史审计日志 | ~19 MB | ~20 MB |
| **16 源 / 13 适配器 / 59 模型(本机生产)** | ~28 MB | **~32–35 MB** |
> 历史参考:本项优化前同一生产配置为 **~105 MB**。降幅来自三处:审计日志的**原始记录**
> 不再常驻内存(约 25 MB;统计聚合仍扫全量,但扫完即释放)、Lua 状态池不再单调增长、
> 以及下面两个运行时开关。
内存构成(生产实例分段测量,`/proc/<pid>/smaps`):
| 区域 | RSS | 说明 |
|---|---|---|
| Go 堆 | ~14 MB | provider/registry/scheduler 结构 + 连接池缓冲 |
| 其他匹名(线程栈 / LuaJIT chunk / runtime) | ~12 MB | 与线程数、已加载适配器数相关 |
| 二进制 text+rodata | ~8 MB | 映射的可执行文件页(只读、可被内核回收) |
| Go runtime 预留 | ~4 MB | VSZ 上看到的 2 GB+ 是地址空间预留,不占物理内存 |
| 共享库 | ~3 MB | libc / libluajit / libm |
**两个推荐的部署开关**(只适用于环境变量,无需改代码):
```ini
# /etc/systemd/system/modelrouter.service
Environment=GOGC=50
Environment=MALLOC_ARENA_MAX=2
```
- `MALLOC_ARENA_MAX=2`:LuaJIT 的分配走 cgo → glibc malloc,glibc 默认允许 `8×nproc`
个 per-thread arena,每个碰到 malloc 的 OS 线程会占用一个(各约 1 MB,且**不归还给系统**)。
实测从 8–15 个 arena(约 7–12 MB)降到 **0**。
- `GOGC=50`:把 Go 堆增长目标减半。**单独使用无效**(省下的堆会立即被更多 glibc
arena 吃掉,实测 20.3 → 21.5 MB 反而变大),必须与 `MALLOC_ARENA_MAX` 配合,
两者同时开启才降 **~19%**。网关是 I/O 密集型(本机 9 小时仅消耗 1min10s CPU),
多出的 GC 周期与它的空闲 CPU 相比可忽略。
> 桌面版(Electron)已在 `cmd/gui/main.js` 里默认为内嵌核心注入这两个开关;手动 export 同名
> 环境变量可覆盖。如果你用自己的 systemd unit / 容器,建议照上面加上。
可选:`GOMEMLIMIT=48MiB` 作软上限,实测再省 ~1 MB;代价是逐近上限时 GC 转激进,
温和场景不必开。
## thinking 预算
请求体带 `"thinking": {"enabled": false}` 时关闭推理;`"thinking": {"enabled": true,
"budget_tokens": 4096}` 指定预算。内核只定义这个**中立**语义字段,映射到具体协议由
适配器负责(Anthropic `thinking.budget_tokens`、Gemini `thinkingConfig.thinkingBudget`、
DeepSeek `thinking.type = "disabled"`)。内核不再向适配器泄漏 `disable_thinking` /
`extra_body` 这类私有字段。

View File

@ -15,6 +15,37 @@ ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plu
---
## 0. 插件 SDK:唯一的公开接口
插件与网关之间**只有一个**公开接口表 `modelrouter`。它不是 Go 包的导出符号,
而是内核在启动插件 VM 时注入的 Lua 表;插件通过它调用内核能力,永远不需要
(也不应该)依赖任何 `__llmsproxy_*` 全局、Go 结构体或内部函数名。
```lua
modelrouter.sdk_version -- 字符串,契约版本;SDK 形状变化才递增
modelrouter.http(spec) -- 受出站白名单约束的 HTTP 请求
modelrouter.secrets -- 本插件专用的凭据保险库(set/get/delete/keys)
modelrouter.kernel -- 内核动作面(actions/call,受运维授权约束)
modelrouter.adapters -- 安装/更新/删除本插件自己的适配器
modelrouter.plugin_dir -- 本插件所在目录(内核给出的 ground truth)
```
规则:
- **只依赖上表**。内核重命名内部全局、改实现或加守卫时,插件不受影响;
这正是把六个散落的全局收进一张表的原因。
- **能力可能缺失**。未授予的能力(例如出站 HTTP 全禁)在表里是 `nil` 而不是
"存在但一调用就报错",插件应先判断再使用。
- **`sdk_version` 用来做兼容分支**。增量字段不递增版本;字段删除或调用约定
变化才递增。
- **历史别名**:`llmsproxy`(旧产品名)与 `__llmsproxy_{http,secrets,kernel,
adapters,plugin_dir}` 仍然指向同一批对象,只为已安装的插件继续可用而保留,
已废弃;新插件使用 `modelrouter.*`。
- 环境变量:插件目录优先读 `MODELROUTER_PLUGIN_DIR`,回退旧名
`MODELROUTER_PLUGIN_DIR`。
---
## 1. 快速上手
一个最小的插件:
@ -65,16 +96,14 @@ return plugin -- 必须返回一个 table
core.New
└─ lua.NewVM(adapter_dir).Start() 适配器状态
└─ lua.NewPlugins(vm, plugin_dir)
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
└─ LoadDir() 按文件名字典序逐个加载
`plugin_dir` 中的 Lua 文件由网关逐个加载;安装包不携带业务插件,也不会自动创建或写入插件文件。首次启用时,先把需要的 `.lua` 插件放入该目录,再启动网关。
```
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
的文件说了算,删除或编辑内置插件都是真实生效的操作。
插件目录由运维独立管理。网关不会 seed、覆盖或删除其中的插件;目录缺失时只会跳过插件加载,不影响网关启动。
### 2.1 热更新
@ -294,7 +323,7 @@ PUT /api/plugins/{name}/state 替换状态(admin)
"loaded": true
}],
"hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
"plugin_dir": "/etc/llmsproxy/plugins"
"plugin_dir": "/etc/modelrouter/plugins"
}
```
@ -303,13 +332,13 @@ PUT /api/plugins/{name}/state 替换状态(admin)
### 5.4 定时任务(`PLUGIN.schedule`)—— 浏览器关着也会跑
要求插件做“随时间发生”的事(自动签到、token 轮换、额度轮询)时,声明一个
要求插件做“随时间发生”的事(定时刷新、token 轮换、指标采集)时,声明一个
调度表:
```lua
plugin.schedule = {
{ fn = "refresh_token", every = 3600 }, -- 每小时(秒)
{ fn = "daily_checkin", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间)
{ fn = "daily_refresh", every = 86400, at = "03:30" }, -- 每天固定时刻(本地时间)
{ fn = "poll_quota", every = 300, jitter = 30 }, -- 首次触发加 0-30s 抖动
}
function plugin.refresh_token()
@ -428,7 +457,7 @@ Cookie 鉴权都自然生效,无需暴露额外端口。
-- 每小时的定时任务:轮换 token 并重写适配器
function plugin.refresh_token()
local tok = fetch_new_token() -- 插件自己的取 token 逻辑
local name, err = __llmsproxy_adapters.install(
local name, err = modelrouter.adapters.install(
"myauth",
'return { name = "myauth", endpoint = "/chat/completions",' ..
' headers = { ["Authorization"] = "Bearer ' .. tok .. '" } }'
@ -439,9 +468,9 @@ end
| 调用 | 参数 | 返回 |
|---|---|---|
| `__llmsproxy_adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
| `__llmsproxy_adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
| `__llmsproxy_adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
| `modelrouter.adapters.install(name, source)` | 适配器名 + Lua 源码 | `(生效名, nil)` 或 `(nil, err)` |
| `modelrouter.adapters.update(name, source)` | 同上(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
| `modelrouter.adapters.remove(name)` | 适配器名(只限自己装的) | `(true, nil)` 或 `(nil, err)` |
出错时不抛异常,而是返回 `(nil, err)`——与钩子/HTTP 处理器同一套约定,
一个手滑的插件错误不会带走网关。
@ -494,24 +523,66 @@ end
### 6.4 命名
- 全局 `__llmsproxy_plugin` 存放插件返回的 table,**不要占用**
- 全局 `__llmsproxy_plugin` 是内核存放插件返回 table 的**内部位置**,不属于 SDK,**不要占用**
- 插件间状态完全隔离,一个插件改不了另一个的全局
---
## 7. 内置计费插件
## 7. Billing 插件示例
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
`internal/lua/plugins/billing.lua` 是仓库中的可选插件示例,不随核心安装包分发,也不会在启动时自动安装。需要使用计费功能时,应将该脚本单独复制到配置的 `plugin_dir` 后再重启网关。
### 7.1 计费维度
### 7.0 分层:billing 完全在插件里,内核没有任何 billing 代码
| 维度 | 用途 | 优先级 |
`billing` 是**插件自己的事**:规则文件、编译、计价、账目、规则编辑页都由
`plugins/billing.lua` 负责。内核里没有 billing 的配置结构、端点、编译或迁移逻辑
—— 它只提供所有插件通用的能力(加载 `.lua`、插件状态持久化、插件目录、
`plugin.http` 路由、插件可用的 `io`/`os`)。
| 项 | 位置 |
|---|---|
| 规则文件 | `<plugin_dir>/billing.json`(插件自己读写;内核不知道这个文件) |
| 计费账目 | 插件状态文件 `<plugin_dir>/.billing.state.json`(内核的通用状态持久化) |
| 规则编辑 API | `/api/plugins/http/billing/rules`(GET/PUT/POST/DELETE,插件路由) |
| 编译/计价 | 插件内 `compileRules()` / `priceFor()` / `costFor()` |
规则以**源名**(`source`)为键,而不是 URL:URL→源名的映射属于内核配置,插件
不需要为了它和内核对接;编辑页在浏览器里,直接取 `/api/sources` 给运营选源名。
`url: "*"` 只表示"全局模型价目表"(模型维度本来就不分源)。
> 历史:早期 `billing:` 写在 `config.yaml`、编译在 Go 的 `internal/billing` 里。
> GitCode issue #1 的根因正是"Go 与 Lua 各写一半覆盖语义",组合起来把模型价
> 静默清零。现在内核侧 billing 代码已全部删除,规则文件也由插件自持。
### 7.1 计费维度### 7.1 计费维度
| 维度 | 用途 | token 价覆盖顺序 |
|---|---|---|
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
| `models` | 模型的 per-token 价 | 高 |
| `default` | 兜底价(通常全 0) | 最低 |
| `models` | 模型的 per-token 价 | 中 |
| `sources` | 源级价:固定价,或覆盖该源全部模型的 token 价 | 高 |
| `keys` | 单个网关 key 的覆盖价 | **最高** |
**token 价**优先级:`keys` > `models` > `default`。
**token 价**优先级:`keys` > `sources` > `models` > `default`。
(实现顺序就是"后写覆盖前写":`default` → `models` → `sources` → `keys`;
所以**源级 token 价会覆盖模型价**。这是有意的:运营要能给某个源整体改价。)
⚠️ 正因为 `sources` 会覆盖 `models`:**`mode: token` 的规则不产生任何源条目**。
规则未列出的模型会进入未定价统计(可见),而不是静默计 0 —— 这是 issue #1 / PR #2
的收口语义。只有 `free` / `subscription`("边际成本为零")会写有意为之的
`{prompt:0, completion:0}` 零价条目。
规则写法:
```json
{"active":"current","profiles":[{"id":"current","default":"unpriced","rules":[
{"source":"localzen","mode":"free"},
{"source":"codearts","mode":"token",
"models":{"glm-5.3":{"prompt":"0.15","completion":"0.60"}}},
{"url":"*","mode":"token","models":{"some-model":{"prompt":"1.0","completion":"2.0"}}}
]}]}
```
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
**`cache_discount`** 见 §7.7。
@ -599,6 +670,17 @@ curl -H "Authorization: Bearer $ADMIN_KEY" \
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
**profile 级兜底**:`profiles[].default` 可设为 `free` 或 `unpriced`(默认)。
它是插件运行时的策略(编译期写入 `prices.default_mode`):
| `default` | 没有单独规则的源 | `unpriced_reqs` |
|---|---|---|
| `unpriced`(省略时) | 零价,计入未定价债务 | +1 |
| `free` | 零价,视为**刻意免费** | 不增加 |
显式 `mode: unpriced` 的源即使在 `default: free` 下也仍然计入("只统计不收费"
的声明不能被兜底策略吞掉)。
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
`total` 与各维度,只有金额是 0。
@ -651,7 +733,7 @@ cached = cache_hit_tokens → 全价 × cache_discount
插件的 `log(level, msg)` 输出到网关的日志:
```
journalctl -u llmsproxy -f | grep -i plugin
journalctl -u modelrouter -f | grep -i plugin
```
---

111
docs/routing.md Normal file
View File

@ -0,0 +1,111 @@
# 路由与调度
> 本文档由 README 拆分而来:模型路由、AUTO 链、冷却与半冷却、源与适配器、源模板。
> 返回 [README](../README.md)。
## 模型路由
`/v1/chat/completions` 的 `model` 解析顺序:
1. `source/model` 或 `source:model` 前缀 → 指定源;
2. 精确匹配某个源的 `model`;
3. 配置 `default_model: AUTO` 时 → 走优先级页保存的 AUTO 链(见下);
4. 否则回落(返回错误)。
任何 OpenAI 客户端,只要 `model` 设为某个源的 `name/任意名`,即可锁定走该源;
设为 `AUTO`(或网关配了 `default_model: AUTO`)即自动按优先级链选源。
## AUTO 链(优先级页)
AUTO 调度**只**由优先级页保存的规则决定,源配置里的 `models[].priority` 数字不再
参与调度、也不再显示。**聊天与生图是两套独立的链**,在优先级页面通过「聊天 / 生图」
按钮切换编辑:
- **聊天链**(`runtime_file` 的 `auto` 字段):服务 `POST /v1/chat/completions`
的 `model: AUTO` 请求;只接受 `kind: chat` 模型,配置在链里的生图槽位会被忽略。
- **生图链**(`runtime_file` 的 `auto_image` 字段):服务 `POST /v1/images/generations`
的 `model: AUTO` 请求;只接受 `kind: image` 模型。未配置生图链时,AUTO 生图回落
到「所有含生图模型的源、按注册顺序」的旧行为,兼容旧配置。
每条规则 = 一个「槽位」:`{ model, source, tier, token_quota, period, hours }`。
- `tier` 表示优先级档位:同一档的模型并排、共享该优先级;档位从上到下递减。
- 同一模型可配置多个槽位(如 A 源低配 → B 源低配 → A 源高配 → B 源高配),按档位顺延。
- `token_quota` > 0 时该槽位在重置周期内用满即顺延到下一槽位;`period` 支持
`hour` / `week` / `month` / `nhour`(配合 `hours`),空 = 不限。
- 同 tier 内按「偏好分」排序,失败槽位冷却后自动跳过;冷却 / 配额耗尽 / hard
错误会顺延到下一 tier,全部失败时返回 503 并附上每档失败摘要。
## 冷却与半冷却探测(自愈调度)
槽位失败后进入指数退避冷却(5s → 10s → 20s …,上限 **5 分钟**)。冷却不是全时段
硬闸:
- **前半段完全静默**:刚失败的槽位不接任何流量,避免对故障上游持续施压。
- **后半段放行 1 个探测**:窗口过半后,同一 `(源, 模型)` 最多允许 **一个** 请求作为
探测通过。探测槽数 = `clamp(max_concurrent / 10, 1, 2)`——`max_concurrent: 10`
的源恰好放行 1 个探测请求。
- **探测排在最后**:同 tier 内探测候选永远排在健康候选之后,只有没有正常槽可用时
才承接真实流量,因此探测不会抢走可用容量。
- **探测即真实请求**:成功则立即清零失败计数与冷却,槽位当场回到正常轮询——
上游恢复/额度恢复后不必等满整个冷却窗口。失败则重开一个新窗口,下次探测顺延到
新窗口的中点,不会连续重试。
分类冷却策略:
| 情况 | 冷却 | 说明 |
|---|---|---|
| 传输错误 / 5xx | 5s→10s→20s…,上限 5min | 指数退避 |
| 401 / 403 凭据错误 | 10min | 换 key 后由下一次探测自动接回 |
| 429 限流 | 30s 固定 | 「太快」不等于「坏了」,不进指数阶梯 |
| 配额耗尽(`insufficient_quota` / `余额不足` 等) | 对齐配额窗口,上限 30min | **不进指数阶梯、不涨失败计数**,额度恢复即可用 |
WebUI「优先级」页的槽位健康标签会区分 `冷却` / `待探测` / `探测中`,悬停可看窗口
起点、探测放行时刻与完全恢复时刻。
## 源(Source)与适配器(Adapter)的关系
- **源** 是到某个上游的连接描述:`name`、`base_url`、`api_key`、模型列表与优先级。
- **适配器** 是协议转换逻辑(Lua 脚本):把统一 OpenAI 格式请求转成上游原生格式,
再把上游响应/流式分块转回统一格式。
- 一个适配器可被多个源复用(如 `openai.lua` 同时服务多个 OpenAI 兼容站点);
同一个源也可以切换不同适配器(改 `adapter` 字段即可)。
- 源决定“连谁、暴露哪些模型”,适配器决定“怎么对话”——二者在 `sources[]` 条目中
通过 `adapter` 字段关联。
加载流程(`internal/core` 的 `Core` 负责装配):
1. 启动时 `lua.NewVM(adapter_dir)` 加载全部适配器:内置适配器(编译期 embed)+
`adapter_dir` 下的同名覆盖文件;内置文件写在代码内,覆盖文件要求更高优先级。
2. `config.Load` 读取 `config.yaml`,`config.NewStore(runtime_file)` 读 WebUI 改动的
运行时源,二者按名称合并成完整源列表。
3. `rebuildRegistry` 为每个源创建 `provider.Provider`(持有目标适配器),并按源的
并发上限配置适配器 worker 池**上限**(不预分配,见下)。
4. 请求进来时 `Registry` 按 `model` 路由到 Provider,Provider 调适配器
`transform_request` → HTTP 发送 → `transform_response` / `transform_stream_chunk`。
WebUI 上的"新增/编辑源"、"上传 Lua 适配器"、"改 AUTO 优先级链"与"密钥模型范围"
都即时生效(写入运行时文件或 `adapter_dir` 后重新装配,无需重启);直接编辑
`config.yaml` / `adapter_dir` 下的文件则需要重启进程才会重新加载。
在 WebUI 删除源/适配器时:运行时(WebUI 创建)的源与 `.lua` 文件会真正移除;
`config.yaml` 中定义的基源无法改写配置文件,采用删除标记隐藏(重启后仍隐藏),
在 UI 里重新添加同名源即可恢复。
## 源模板(多 key 负载均衡)
多个上游 key 共享同一套配置(URL / 适配器 / 模型列表 / 并发 / rpm)时,逐一复制
整段 source 太冗余。**源模板**保存除 `name` 和 `api_key` 以外的全部源字段,
从模板一键创建多个仅 key 不同的源,天然支持多 key 负载均衡(每个展开源独立调度、
独立冷却、独立健康状态)。
- WebUI「源」页右上角「**模板管理**」按钮:列出全部模板,可编辑 / 删除 / 新建。
- 添加源弹窗右上角两个按钮:
- **从模板创建** → 选模板 → 表单自动填充(name + key 仍需你填);
- **存为模板** → 把当前表单非 key/name 字段存为新模板。
- 模板持久化在 `runtime_file` 的 `source_templates` 字段,与运行时源一样热管理,
改完即时生效。模板本身不含密钥,可以安全共享 / 纳管。
模板不会自己变成源——它只是「配方」。真正承担流量的是从模板创建出来的、带 key
的具体源。

23
docs/webui.md Normal file
View File

@ -0,0 +1,23 @@
# WebUI
> 本文档由 README 拆分而来。
> 返回 [README](../README.md)。
## WebUI
内置管理界面(`GET /`),登录后可在浏览器完成:
- **状态页**:源在线状态(实时探测 + 悬停看错误)、模型/源/key 用量统计、请求记录,
支持按时间范围导出 CSV;点击模型可生成 pin 到该模型的连接配置。
- **对话页**:流式/非流式调试。
- **密钥页**:创建/编辑网关 key,为每个 key 配模型范围(模型 + 源 + token 配额 + 周期),
管理员管理全部 key,用户只看到自己的 key。每个模型砖块显示自己的配额徽标
(如 `1.0K 77×·1h`,未设配额显示 `∞`),点开可编辑该模型的 token 配额、
请求数配额与重置周期 —— 配额按模型独立生效,一个用满不影响同一 key 的其它模型。
「我的密钥」页对用户展示本 key 的模型范围与各自配额。
- **优先级页**:拖拽积木配置 AUTO 链档位。
- **源页**:在线增删改上游源(API key 等敏感字段加密落盘)。
- **适配器页**:上传 / 删除 Lua 适配器脚本。
改动写入 `runtime_file`(重启仍生效)。

View File

@ -1,170 +0,0 @@
package e2e
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestChannelsDevicePollE2E 用**真二进制**跑通 channels 插件的 device-poll 登录:
//
// 发起登录(start_login) → 页面轮询(poll_login) 推进设备码换 token
// → 凭据入保险库 → add_source 把平台接成上游源 → /v1/models 里出现其模型
//
// 这是对参考仓库 dsh-our-free-model「登录→领额度→接入源」形态的端到端对齐:
// 行为(HTTP 决策分支、会话推进、最终可路由)在真进程里验证,而不是单元桩。
//
// 假上游模拟 MiniMax 的两个端点:设备码申请 + token 轮询(脚本化逐次响应)。
func TestChannelsDevicePollE2E(t *testing.T) {
// ---- 假平台上游(loopback,白名单天然放行)----
var tokenHits int32
var tokenScript atomic.Value // []scriptStep
tokenScript.Store([]scriptStep{})
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV-E2E","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&tokenHits, 1)) - 1
script := tokenScript.Load().([]scriptStep)
w.Header().Set("Content-Type", "application/json")
if i >= len(script) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if script[i].code != 0 && script[i].code != 200 {
w.WriteHeader(script[i].code)
}
fmt.Fprint(w, script[i].body)
case "/v1/models":
// 接入源后内核会探测可达;回一个 OpenAI 形状的模型列表
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"object":"list","data":[{"id":"MiniMax-M3","object":"model"}]}`)
case "/v1/chat/completions":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
w.WriteHeader(404)
}
}))
defer plat.Close()
// ---- 真二进制 + 配置(带 plugin_dir,让 bundled channels 落地 + 授权可持久化)----
dir := t.TempDir()
addr := mustAddr(t)
pluginDir := filepath.Join(dir, "plugins") // 不预建:SeedBundled 见空目录才落地内置插件
cfg := "listen: " + addr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: none\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + pluginDir + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources: []\n"
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
bin := buildBinary(t)
g := startGatewayNoModels(t, bin, addr, cfgPath)
call := func(method, path, body string) (int, map[string]interface{}) {
resp, raw := g.do(method, path, body, true)
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
return statusOf(resp), m
}
// 1) 授权内核动作(运维批准 add_source 等)+ 把 minimax 端点指到假上游。
st, m := call("PUT", "/api/plugins/channels/kernel",
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
if st != 200 {
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
}
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"minimax","base_url":%q,"device":{"code_url":%q,"token_url":%q}}`,
plat.URL+"/v1", plat.URL+"/oauth2/device/code", plat.URL+"/oauth2/token"))
if st != 200 {
t.Fatalf("set_endpoints 失败: status=%d body=%v", st, m)
}
// 2) 发起 device-poll 登录:拿到会话 + user_code。
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"minimax"}`)
if st != 200 {
t.Fatalf("start_login status=%d body=%v", st, m)
}
sess, _ := m["session"].(map[string]interface{})
if sess == nil || sess["user_code"] != "WXYZ-1234" {
t.Fatalf("start_login 未透传 user_code: %v", m)
}
sid, _ := sess["id"].(string)
if sid == "" {
t.Fatal("无会话 id")
}
// 3) 第一次轮询:上游 pending → 会话仍 pending。
tokenScript.Store([]scriptStep{{code: 400, body: `{"error":"authorization_pending"}`}})
atomic.StoreInt32(&tokenHits, 0)
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] != "pending" {
t.Fatalf("轮询1 应 pending, 实得 %v", m)
}
// 4) 第二次轮询:上游发 access_token → 会话 completed,凭据入库 + 接入源。
tokenScript.Store([]scriptStep{{code: 200, body: `{"access_token":"MM-E2E-TOKEN","refresh_token":"MM-RF","nickname":"e2e-user"}`}})
atomic.StoreInt32(&tokenHits, 0)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] == "completed" || m["state"] == "failed" {
break
}
time.Sleep(150 * time.Millisecond)
}
if m["state"] != "completed" {
t.Fatalf("轮询2 应 completed, 实得 %v", m)
}
if m["account"] != "e2e-user" {
t.Fatalf("account 应取 nickname, 实得 %v", m)
}
// 5) 接入源的终局证据:/v1/models 里出现 channels-minimax 的模型。
deadline = time.Now().Add(5 * time.Second)
var modelsBody string
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "MiniMax-M3") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "MiniMax-M3") {
t.Fatalf("接入源后 /v1/models 未出现 MiniMax-M3: %s", modelsBody)
}
}
type scriptStep struct {
code int
body string
}
// startGatewayNoModels 与 startGateway 相同,但就绪探针不要求 /v1/models 返回
// 非空:本用例启动时没有任何源,/v1/models 是空列表(200 即就绪)。
func startGatewayNoModels(t *testing.T, bin, listen, cfgPath string) *gatewayUnderTest {
t.Helper()
return startGateway(t, bin, listen, cfgPath)
}
var _ = net.Listen
var _ = io.Discard

View File

@ -1,209 +0,0 @@
package e2e
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// TestChannelsFlowsE2E 用真二进制补齐另外三条流程的端到端:
//
// Cline(WorkOS device-poll 两步:authenticate → register 换最终凭据)
// paste(opencode:直接 complete_login 贴 API key)
// device-poll 授权页(qoder:本地造 PKCE,把手动授权页给用户,再凭据完成)
//
// 终局证据与 TestChannelsDevicePollE2E 同一标准:/v1/models 出现该平台模型。
func TestChannelsFlowsE2E(t *testing.T) {
var authHits, regHits int32
plat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
// ---- Cline(WorkOS)----
case "/user_management/authorize/device":
fmt.Fprint(w, `{"device_code":"CD-E2E","user_code":"CLNE-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/user_management/authenticate":
if atomic.AddInt32(&authHits, 1) == 1 {
w.WriteHeader(400)
fmt.Fprint(w, `{"error":"authorization_pending"}`)
return
}
fmt.Fprint(w, `{"access_token":"WO-E2E-AT","refresh_token":"WO-E2E-RT"}`)
case "/api/v1/auth/register":
atomic.AddInt32(&regHits, 1)
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-E2E-AT","refreshToken":"CLINE-E2E-RT","userInfo":{"email":"dev@example.com"}}}`)
// ---- 上游推理面(opencode/qoder/cline 的 base_url 都指到这里)----
case "/v1/models":
fmt.Fprint(w, `{"object":"list","data":[{"id":"big-pickle","object":"model"},{"id":"qmodel_38max","object":"model"},{"id":"stealth/space-bunny-alpha","object":"model"}]}`)
case "/v1/chat/completions":
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
default:
w.WriteHeader(404)
}
}))
defer plat.Close()
dir := t.TempDir()
addr := mustAddr(t)
pluginDir := filepath.Join(dir, "plugins")
cfg := "listen: " + addr + "\n" +
"gateway_keys:\n - sk-e2e-0001\n" +
"default_model: none\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"plugin_dir: " + pluginDir + "\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"sources: []\n"
cfgPath := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
bin := buildBinary(t)
g := startGatewayNoModels(t, bin, addr, cfgPath)
call := func(method, path, body string) (int, map[string]interface{}) {
resp, raw := g.do(method, path, body, true)
var m map[string]interface{}
_ = json.Unmarshal([]byte(raw), &m)
return statusOf(resp), m
}
st, m := call("PUT", "/api/plugins/channels/kernel",
`{"actions":["add_source","status","list_sources","probe_all","reset_health"],"all_read":false}`)
if st != 200 {
t.Fatalf("授权内核动作失败: status=%d body=%v", st, m)
}
// ---------- ① Cline:WorkOS 设备码两步 ----------
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"cline","base_url":%q,"device":{"code_url":%q,"token_url":%q},"register":{"url":%q}}`,
plat.URL, plat.URL+"/user_management/authorize/device",
plat.URL+"/user_management/authenticate", plat.URL+"/api/v1/auth/register"))
if st != 200 {
t.Fatalf("set_endpoints(cline) 失败: status=%d body=%v", st, m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"cline"}`)
if st != 200 {
t.Fatalf("cline start_login status=%d body=%v", st, m)
}
sess, _ := m["session"].(map[string]interface{})
if sess == nil || sess["user_code"] != "CLNE-1234" {
t.Fatalf("cline start_login 未透传 user_code: %v", m)
}
sid, _ := sess["id"].(string)
// 第一拍 pending(authorization_pending),第二拍 completed
atomic.StoreInt32(&authHits, 0)
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] != "pending" {
t.Fatalf("cline 轮询1 应 pending, 实得 %v", m)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
st, m = call("GET", "/api/plugins/http/channels/login/status?id="+sid, "")
if m["state"] == "completed" || m["state"] == "failed" {
break
}
time.Sleep(150 * time.Millisecond)
}
if m["state"] != "completed" {
t.Fatalf("cline 轮询2 应 completed, 实得 %v", m)
}
if m["account"] != "dev@example.com" {
t.Fatalf("cline account 应取 register userInfo.email, 实得 %v", m)
}
if atomic.LoadInt32(&regHits) != 1 {
t.Fatalf("register 应恰好调用一次, 实得 %d", regHits)
}
// 凭据必须是 register 的最终 token,不是 WorkOS token
var modelsBody string
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "stealth/space-bunny-alpha") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "stealth/space-bunny-alpha") {
t.Fatalf("cline 接入后 /v1/models 未出现其模型: %s", modelsBody)
}
// ---------- ② paste(opencode):complete 直接贴 key ----------
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"opencode","base_url":%q}`, plat.URL))
if st != 200 {
t.Fatalf("set_endpoints(opencode) 失败: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"opencode"}`)
if st != 200 {
t.Fatalf("opencode start_login status=%d", st)
}
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "paste" {
t.Fatalf("opencode 会话 flow 应 paste: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login/complete",
`{"platform":"opencode","credential":"OC-E2E-KEY"}`)
if st != 200 || m["ok"] != true {
t.Fatalf("opencode complete 失败: status=%d body=%v", st, m)
}
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "big-pickle") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "big-pickle") {
t.Fatalf("opencode 接入后 /v1/models 未出现 big-pickle: %s", modelsBody)
}
// ---------- ③ device-poll(qoder):给授权页 + 凭据完成 ----------
// qoder 曾走 manual(返回 manual_hint);现已改为真实的 PKCE 授权页流程
// (本地造 challenge/verifier,服务端按 nonce 找回待授权会话)。
st, m = call("POST", "/api/plugins/channels/call/set_endpoints", fmt.Sprintf(
`{"platform":"qoder","base_url":%q}`, plat.URL))
if st != 200 {
t.Fatalf("set_endpoints(qoder) 失败: %v", m)
}
st, m = call("POST", "/api/plugins/http/channels/login", `{"platform":"qoder"}`)
if st != 200 {
t.Fatalf("qoder start_login status=%d", st)
}
// 真流程必须给出授权页 URL(而不是「不支持自动登录」的 manual_hint),
// 且会话声明为 device-poll。
if hint, _ := m["manual_hint"].(string); hint != "" {
t.Fatalf("qoder 已不再走 manual,不应返回 manual_hint: %q", hint)
}
if sess, _ = m["session"].(map[string]interface{}); sess == nil || sess["flow"] != "device-poll" {
t.Fatalf("qoder 会话 flow 应 device-poll: %v", m)
}
if authURL, _ := m["url"].(string); !strings.Contains(authURL, "challenge=") {
t.Fatalf("qoder 授权页 URL 应带 PKCE challenge,实得 %q", authURL)
}
st, m = call("POST", "/api/plugins/http/channels/login/complete",
`{"platform":"qoder","credential":"QD-E2E-KEY"}`)
if st != 200 || m["ok"] != true {
t.Fatalf("qoder complete 失败: status=%d body=%v", st, m)
}
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
resp, raw := g.do("GET", "/v1/models", "", true)
modelsBody = raw
if resp != nil && resp.StatusCode == 200 && strings.Contains(raw, "qmodel_38max") {
break
}
time.Sleep(150 * time.Millisecond)
}
if !strings.Contains(modelsBody, "qmodel_38max") {
t.Fatalf("qoder 接入后 /v1/models 未出现 qmodel_38max: %s", modelsBody)
}
}

View File

@ -1,4 +1,4 @@
// Package e2e runs the real llmsproxy binary against mocked upstreams over
// Package e2e runs the real modelrouter binary against mocked upstreams over
// real HTTP: startup, auth, chat, streaming SSE, image generation, and
// AUTO failover when a higher-priority source fails.
package e2e
@ -100,15 +100,15 @@ type gatewayUnderTest struct {
func buildBinary(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "llmsproxy")
bin := filepath.Join(dir, "modelrouter")
if runtime.GOOS == "windows" {
bin += ".exe" // go build writes the exact name; Windows needs the suffix to exec
}
out, err := exec.Command("go", "build", "-tags", "luajit", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
out, err := exec.Command("go", "build", "-tags", "luajit", "-o", bin, "modelrouter/cmd/modelrouter").CombinedOutput()
if err != nil && runtime.GOOS == "windows" {
// local Windows dev box may lack LuaJIT: fall back to the bundled
// Lua runtime (the e2e adapters used here are passthrough-only)
out, err = exec.Command("go", "build", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
out, err = exec.Command("go", "build", "-o", bin, "modelrouter/cmd/modelrouter").CombinedOutput()
}
if err != nil {
t.Fatalf("build: %v\n%s", err, out)
@ -187,18 +187,20 @@ func writeConfig(t *testing.T, dir, listenAddr string, upstreams map[string]*moc
sb.WriteString("runtime_file: " + filepath.Join(dir, "runtime.json") + "\n")
sb.WriteString("sources:\n")
order := []string{"good", "fallback", "image"}
prio := map[string]int{"good": 100, "fallback": 50}
for _, name := range order {
u, ok := upstreams[name]
if !ok {
continue
}
if name == "image" {
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n priority: 80\n")
sb.WriteString(" - name: imagegen\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: flux-1\n kind: image\n")
} else {
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n priority: " + fmt.Sprint(prio[name]) + "\n")
sb.WriteString(" - name: " + name + "\n base_url: " + u.baseURL + "\n adapter: openai\n models:\n - id: " + name + "-m\n")
}
}
// AUTO 链现在是显式配置(25970a2 移除了 models.priority)。tier 越小越先试。
sb.WriteString("auto:\n - model: good-m\n source: good\n tier: 1\n")
sb.WriteString(" - model: fallback-m\n source: fallback\n tier: 2\n")
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil {
t.Fatalf("write config: %v", err)

2
go.mod
View File

@ -1,4 +1,4 @@
module llmsproxy
module modelrouter
go 1.25.0

View File

@ -1,323 +0,0 @@
package billing
import (
"fmt"
"strings"
"llmsproxy/internal/config"
)
// Compile turns one billing profile into the prices table the billing plugin
// expects, resolving URL rules against the gateway's actual sources.
//
// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL
// because that is what a provider's price list is keyed on, and because several
// sources can point at the same URL. The plugin, however, looks up by
// `payload.source` (a source NAME) and by model — it has no idea what URL a
// request went to. So the URL match happens here, at compile time, where the
// config's name->base_url mapping is known, and the result is expressed in the
// dimensions the plugin already supports.
//
// Profiles are the "let the user choose" axis: the same URL can appear in
// several profiles and switching recomputes this table, so a gateway can be
// repriced without editing the plugin.
func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) {
return CompileOpts(profile, sources, false)
}
// CompileOpts is Compile with an explicit policy for rules that match no
// source.
//
// The default is STRICT and that is correct for startup: a profile with a
// typo'd URL prices nothing, every request on it is recorded as unpriced, and
// the bill silently comes out wrong. Failing the load is the right response.
//
// The rules editor needs the opposite. An operator editing a half-finished
// profile — adding a rule before the source that will use it exists, or
// renaming a URL while typing — must be able to save and then see the warning,
// not be blocked by an error they can only resolve by guessing. Lenient mode
// keeps the offending rule in the config and drops it from the compiled table,
// so it is preserved as text and clearly reported, without pretending it
// prices anything.
func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (map[string]interface{}, error) {
if profile == nil {
return nil, fmt.Errorf("no billing profile")
}
prices := map[string]interface{}{
"currency": "USD",
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
"sources": map[string]interface{}{},
"models": map[string]interface{}{},
"keys": map[string]interface{}{},
}
if profile.Default != "" {
prices["default_mode"] = profile.Default
}
// Currency is a profile-level statement; a rule may override it.
for i := range profile.Rules {
if c := profile.Rules[i].Currency; c != "" {
prices["currency"] = c
break
}
}
matched := map[string]bool{}
for i := range profile.Rules {
rule := &profile.Rules[i]
targets := matchSources(rule.URL, sources)
if len(targets) == 0 && rule.URL != "*" {
// A rule for a URL no source uses is almost always a typo (or a
// source that was removed). Failing loudly beats a profile that
// silently prices nothing — the whole reason this is a config file
// instead of a hand-written JSON blob.
if lenient {
continue // kept in the config text, reported as a warning
}
return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL)
}
for _, srcName := range targets {
if matched[srcName] {
// First rule wins. Two rules matching one source is ambiguous,
// and silently letting the later one win makes the file's
// meaning depend on ordering the operator cannot see.
continue
}
matched[srcName] = true
if err := applyRule(prices, rule, srcName, sources); err != nil {
return nil, err
}
}
}
// Sources no rule matched fall through to the profile default, which the
// plugin applies via prices.default. Recording them explicitly means the
// UI can say "this source is unpriced" instead of leaving the operator to
// infer it from a zero.
var unmatched []string
for _, src := range sources {
if !matched[src.Name] {
unmatched = append(unmatched, src.Name)
}
}
if len(unmatched) > 0 {
prices["unmatched_sources"] = unmatched
}
return prices, nil
}
// matchSources returns the source names whose base_url matches pattern.
// "*" matches every source (used as a catch-all default rule).
func matchSources(pattern string, sources []config.Source) []string {
var out []string
if pattern == "*" {
for _, s := range sources {
out = append(out, s.Name)
}
return out
}
want := normalizeURL(pattern)
for _, s := range sources {
if normalizeURL(s.BaseURL) == want {
out = append(out, s.Name)
}
}
return out
}
// normalizeURL compares URLs the way an operator expects: trailing slashes and
// case in the host are differences the provider's price list does not care
// about, and requiring an exact byte match would make the config brittle.
func normalizeURL(u string) string {
u = strings.TrimSpace(u)
u = strings.TrimRight(u, "/")
return strings.ToLower(u)
}
// applyRule writes one rule's pricing for one source into the prices table.
func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error {
switch rule.Mode {
case "free":
// Explicitly priced at zero. This is NOT the same as unpriced: a source
// the operator says is free must not appear in the unpriced warnings,
// or those warnings become noise and stop being read.
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
}
return nil
case "subscription":
// A fixed monthly commitment: the per-request MARGINAL cost is zero, and
// the flat fee is reported separately. Spreading a monthly fee across
// requests would invent a per-request number the provider never charges,
// and it would change every time traffic did.
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
}
fixed, _ := prices["subscriptions"].(map[string]interface{})
if fixed == nil {
fixed = map[string]interface{}{}
prices["subscriptions"] = fixed
}
cur := "USD"
if rule.Currency != "" {
cur = rule.Currency
}
fixed[srcName] = map[string]interface{}{
"monthly": rule.Subscription, "currency": cur,
}
return nil
case "unpriced":
// Deliberately left out of `sources` so the plugin's unpriced_models /
// unpriced_reqs counters catch it. That is the point: a subscription
// plan whose credits cannot be converted to tokens must be VISIBLE as
// unbilled, not quietly estimated.
return nil
case "token":
if len(rule.Models) == 0 {
return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL)
}
models := prices["models"].(map[string]interface{})
for modelID, t := range rule.Models {
entry := map[string]interface{}{}
prompt, err := usdPerM(t.Prompt)
if err != nil {
return fmt.Errorf("model %q prompt: %w", modelID, err)
}
completion, err := usdPerM(t.Completion)
if err != nil {
return fmt.Errorf("model %q completion: %w", modelID, err)
}
entry["prompt"] = prompt
entry["completion"] = completion
if t.CacheDiscount != nil {
entry["cache_discount"] = *t.CacheDiscount
}
// reasoning is optional and stays ABSENT (not nil, not 0) when the
// rule does not set it, which is what makes the billing plugin skip
// the repricing branch entirely. Writing an explicit 0 here would
// mean "thinking is free" and silently under-bill every request
// from a provider that bills it like ordinary output.
if strings.TrimSpace(t.Reasoning) != "" {
rp, err := usdPerM(t.Reasoning)
if err != nil {
return fmt.Errorf("model %q reasoning: %w", modelID, err)
}
entry["reasoning_price"] = rp
}
if lc := t.LongContext; lc != nil {
if lc.Above <= 0 {
return fmt.Errorf("model %q long_context: above must be > 0", modelID)
}
above, err := longContextRate(lc, prompt)
if err != nil {
return fmt.Errorf("model %q long_context: %w", modelID, err)
}
entry["long_context"] = map[string]interface{}{
"above": lc.Above,
// Marginal, not blended. The plugin charges the tokens past the
// threshold at this rate and the rest at the base rate, so the
// threshold composes with a cache discount (which applies to the
// cached portion) instead of forcing the plugin to guess how
// the two interact.
"prompt": above,
}
}
if rule.Peak != nil {
entry["peak"] = compilePeak(rule.Peak)
}
models[modelID] = entry
}
// A specific-URL token rule needs a source entry so a model served
// from THIS url but absent from `rule.Models` is still recognised as
// belonging to a priced provider.
//
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
// says "these model ids have these prices, wherever they come from" —
// and marking every source priced at {0,0} would make priceFor() set
// priced=true for every model the catalogue does NOT list. Paid
// sources (commandcode, alittokenplan, …) would then record $0 instead
// of showing up in unpriced_reqs, which is precisely the silent
// zero-billing failure this whole plugin is built to make visible.
// The model lookup p.models[payload.model] already marks a listed
// model priced on its own, so the wildcard loses nothing.
if rule.URL != "*" {
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
}
}
}
return nil
}
return fmt.Errorf("unknown mode %q", rule.Mode)
}
// usdPerM converts a USD-per-million string into the per-token rate the plugin
// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the
// compiler agree byte-for-byte on what a valid price is — a value that passes
// Validate() but fails here (or vice versa) would be the worst kind of drift.
func usdPerM(s string) (float64, error) {
return config.ParseFloatUSDPerM(s)
}
// compilePeak renders the peak window in the shape the plugin reads:
// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }.
//
// The plugin reads `days` / `hours` pairs. The first version of the deployed
// price table used { start, end, weekdays } — a shape nothing reads — so peak
// traffic was billed at off-peak rates with no error anywhere. Compiling from
// typed config fields removes the chance of writing the wrong key names by hand.
func compilePeak(p *config.BillingPeak) map[string]interface{} {
win := map[string]interface{}{}
if len(p.Weekdays) > 0 {
days := make([]interface{}, 0, len(p.Weekdays))
for _, d := range p.Weekdays {
days = append(days, d)
}
win["days"] = days
}
if len(p.Hours) > 0 {
hours := make([]interface{}, 0, len(p.Hours))
for _, h := range p.Hours {
hours = append(hours, []interface{}{h[0], h[1]})
}
win["hours"] = hours
}
out := map[string]interface{}{"multiplier": p.Multiplier}
if len(win) > 0 {
out["windows"] = []interface{}{win}
}
return out
}
// longContextRate resolves the marginal per-token rate for the tokens ABOVE a
// threshold, given the model's base per-million prompt price.
//
// Two ways to express it are supported because providers publish both: an
// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek
// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a
// silent no-op, and an operator who wrote one meant something.
func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) {
hasPrompt := strings.TrimSpace(lc.Prompt) != ""
hasMult := lc.Multiplier > 0
switch {
case hasPrompt && hasMult:
p, err := usdPerM(lc.Prompt)
if err != nil {
return 0, err
}
// Both given: the absolute rate wins, because it is the one the provider
// actually quotes. Silently multiplying instead would produce a number
// the operator never wrote.
return p, nil
case hasPrompt:
return usdPerM(lc.Prompt)
case hasMult:
return basePerToken * lc.Multiplier, nil
default:
return 0, fmt.Errorf("needs either prompt or multiplier")
}
}

View File

@ -1,214 +0,0 @@
package billing
import (
"testing"
"llmsproxy/internal/config"
)
// The wildcard token rule must not blanket-mark every source as priced.
//
// Why this is a hazard and not a style question: priceFor() sets priced=true as
// soon as the request's SOURCE has an entry in the price table, and that flag
// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model
// catalogue — it prices specific model ids wherever they come from — but
// compiling it into a {0,0,0} entry on all 21 sources made every model the
// catalogue does not list look "priced at zero". Paid providers (commandcode,
// alittokenplan, …) then stopped appearing in the unpriced warnings, which is
// exactly the silent zero-billing failure the plugin exists to surface.
func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) {
sources := []config.Source{
{Name: "free1", BaseURL: "https://free1.example.com/v1"},
{Name: "paid1", BaseURL: "https://paid1.example.com/v1"},
{Name: "paid2", BaseURL: "https://paid2.example.com/v1"},
}
profile := &config.BillingProfile{
ID: "p",
Rules: []config.BillingRule{
{URL: "https://free1.example.com/v1", Mode: "free"},
{
URL: "*", Mode: "token",
Models: map[string]config.BillingToken{
"only-catalogued-model": {Prompt: "1.00", Completion: "2.00"},
},
},
},
}
prices, err := Compile(profile, sources)
if err != nil {
t.Fatalf("compile: %v", err)
}
srcs := prices["sources"].(map[string]interface{})
// The explicitly free source keeps its entry (free is a deliberate 0).
if _, ok := srcs["free1"]; !ok {
t.Error("the explicit free rule lost its source entry")
}
// Paid sources must NOT be marked, or their unlisted models bill $0.
for _, name := range []string{"paid1", "paid2"} {
if _, ok := srcs[name]; ok {
t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+
"models will bill $0 instead of appearing as unpriced", name)
}
}
// The catalogue itself must still be present, or the rule prices nothing.
models := prices["models"].(map[string]interface{})
if _, ok := models["only-catalogued-model"]; !ok {
t.Error("the wildcard rule did not publish its model price")
}
}
// The inverse guard: a token rule for a SPECIFIC url still marks that source,
// because it genuinely declares pricing for the models served from it.
func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) {
sources := []config.Source{
{Name: "mine", BaseURL: "https://mine.example.com/v1"},
{Name: "other", BaseURL: "https://other.example.com/v1"},
}
profile := &config.BillingProfile{
ID: "p",
Rules: []config.BillingRule{{
URL: "https://mine.example.com/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}},
}
prices, err := Compile(profile, sources)
if err != nil {
t.Fatalf("compile: %v", err)
}
srcs := prices["sources"].(map[string]interface{})
if _, ok := srcs["mine"]; !ok {
t.Error("a specific-url token rule must still mark its own source priced")
}
if _, ok := srcs["other"]; ok {
t.Error("an unrelated source was marked priced by a specific-url rule")
}
}
// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the
// thinking-token rate.
//
// A rule that says nothing about reasoning must produce a price entry with NO
// reasoning_price key, so the billing plugin skips the repricing branch and
// bills thinking at the ordinary output rate — which is what every provider
// actually charges. Writing an explicit 0 instead would read as "thinking is
// free" and silently under-bill every request from that model.
func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
// Case 1: no reasoning declared → the key must not appear.
p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00"},
},
}}}
prices, err := Compile(p1, sources)
if err != nil {
t.Fatal(err)
}
entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
if _, present := entry["reasoning_price"]; present {
t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free",
entry["reasoning_price"])
}
// Case 2: declared → present, and converted to per-token like every other rate.
p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"},
},
}}}
prices2, err := Compile(p2, sources)
if err != nil {
t.Fatal(err)
}
e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{})
rp, ok := e2["reasoning_price"].(float64)
if !ok {
t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"])
}
if rp != 6e-6 {
t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp)
}
// A malformed rate must be rejected, not silently ignored: an operator who
// typed "6 USD" expects either that value or an error, never a rule that
// looks applied and bills nothing.
p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"},
},
}}}
if _, err := Compile(p3, sources); err == nil {
t.Error("a non-numeric reasoning rate must fail the compile")
}
}
// TestLongContextStepCompiles pins the long-context step's two accepted forms
// and its rejection of a step that would silently do nothing.
//
// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes
// an absolute "$3.00/M past 200k" — so both must compile. Neither may be
// missing: a step with no rate is a rule that LOOKS like it handles long
// context and does not, which under-bills without any visible sign.
func TestLongContextStepCompiles(t *testing.T) {
sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}}
compile := func(lc config.BillingLongContext) (map[string]interface{}, error) {
p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{
URL: "https://up.example/v1", Mode: "token",
Models: map[string]config.BillingToken{
"m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc},
},
}}}
prices, err := Compile(p, sources)
if err != nil {
return nil, err
}
e := prices["models"].(map[string]interface{})["m"].(map[string]interface{})
return e["long_context"].(map[string]interface{}), nil
}
// Absolute rate (OpenAI's published shape).
lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"})
if err != nil {
t.Fatalf("absolute long_context rate rejected: %v", err)
}
if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 {
t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1)
}
// Multiplier (DeepSeek's shape): 2x of the 1.00/M base.
lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2})
if err != nil {
t.Fatalf("multiplier long_context rejected: %v", err)
}
if lc2["prompt"] != 2e-6 {
t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2)
}
// Both given: the absolute rate wins, because that is the number the
// provider quotes. Multiplying instead would produce a figure the operator
// never wrote.
lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2})
if err != nil {
t.Fatal(err)
}
if lc3["prompt"] != 5e-6 {
t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3)
}
// Neither: a no-op step must fail, not compile into a rule that silently
// bills long context at the base rate.
if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil {
t.Error("a step with neither prompt nor multiplier must be rejected")
}
// A zero threshold would make every request "long".
if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil {
t.Error("above=0 must be rejected")
}
}

View File

@ -7,7 +7,6 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -30,15 +29,6 @@ type Config struct {
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理)
// BillingDSL declares per-URL pricing profiles for the billing plugin.
//
// Declarative, because hand-writing the prices JSON was error-prone in ways
// that looked successful: the first PUT went out without the required
// `prices` wrapper and silently REPLACED the accumulated totals with the
// price table; the second used a peak-window shape the plugin does not read,
// so peak traffic was billed at off-peak rates — no error, the bill just
// quietly halved. A schema-checked config file catches both at load.
BillingDSL *BillingDSL `yaml:"billing,omitempty"`
// PluginVisibility overrides the audience a plugin declared for itself,
// keyed by plugin name: "admin" hides a plugin (and its state) from user
// keys, "all" widens one that shipped admin-only, "user,admin" names roles
@ -62,13 +52,18 @@ const (
DefaultSourceConcurrency = 8
)
// Model is a single exposed model id bound to a source, with priority used by
// AUTO auto selection (higher number = preferred).
// Model is a single exposed model id bound to a source.
//
// ★ 曾经有 `Priority int` 字段,并号称"高者优先参与 AUTO 选源"。**那是错的**:
//
// AUTO 链由 `config.ModelScope.Tier` 驱动(见 scheduler.Chain),而 priority
// 只被 `bestChatModel/bestImageModel` 用于"本源码内部该发哪个模型",
// 与跨源调度毫无关系。把它当成调度依据会让人误以为改个数字就能改顺序,
// 改完发现毫无效果 —— 已彻底删除,避免再次误导。
type Model struct {
ID string `yaml:"id" json:"id"`
Priority int `yaml:"priority" json:"priority"`
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
ID string `yaml:"id" json:"id"`
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
}
// Source describes a single upstream LLM provider.
@ -90,7 +85,14 @@ type Source struct {
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
// upstreams behind geo/IP blocks that only respond through a proxy.
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
// answer a business-level rejection with a status code the kernel would
// otherwise surface to the client — e.g. a per-account concurrency cap that
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
// the matcher is pure DATA (status list + response-body substrings), so no
// provider name ever appears in core.
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
@ -100,9 +102,66 @@ type Source struct {
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
}
// RetryPolicy describes when a failed upstream attempt may be retried BEFORE
// anything is returned to the client. All conditions are data:
//
// OnStatus - retry only when the HTTP status is one of these
// OnBodyContains - ... AND the response body contains one of these substrings
// (empty = status alone decides)
// Delay - wait between attempts ("10s" etc.); parsed on demand
// MaxAttempts - TOTAL attempts including the first (1 or 0 = no retry)
//
// Scope: the retry happens inside one gateway request, so a stream is only
// retried before its first chunk reaches the client (the kernel already holds
// the first chunk back), and a non-stream call is retried as a whole.
type RetryPolicy struct {
OnStatus []int `yaml:"on_status,omitempty" json:"on_status,omitempty"`
OnBodyContains []string `yaml:"on_body_contains,omitempty" json:"on_body_contains,omitempty"`
Delay string `yaml:"delay,omitempty" json:"delay,omitempty"`
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
}
// ShouldRetry reports whether one failed attempt matches this policy.
func (r *RetryPolicy) ShouldRetry(status int, body string) bool {
if r == nil || r.MaxAttempts <= 1 {
return false
}
statusOK := len(r.OnStatus) == 0
for _, s := range r.OnStatus {
if s == status {
statusOK = true
break
}
}
if !statusOK {
return false
}
if len(r.OnBodyContains) == 0 {
return true
}
for _, sub := range r.OnBodyContains {
if sub != "" && strings.Contains(body, sub) {
return true
}
}
return false
}
// DelayDuration is the configured pause between attempts (default 10s).
func (r *RetryPolicy) DelayDuration() time.Duration {
if r == nil || r.Delay == "" {
return 10 * time.Second
}
d, err := time.ParseDuration(r.Delay)
if err != nil || d < 0 {
return 10 * time.Second
}
return d
}
// Load reads and validates a config file. When the file does not exist yet a
// default config is generated at that path first (first-run bootstrap), so a
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
// fresh binary just works: `modelrouter -config /path/to/config.yaml`.
func Load(path string) (*Config, error) {
if _, err := EnsureDefault(path); err != nil {
return nil, err
@ -418,182 +477,6 @@ type GWKey struct {
// chain" so a user can clear an override back to the global default.
func (k GWKey) HasOwnAuto() bool { return len(k.Auto) > 0 }
// BillingDSL holds declarative per-URL pricing profiles for the billing plugin.
//
// Profiles are the "let the user pick" axis: the same upstream URL can be
// declared under multiple profiles, and the operator chooses which is active.
// Switching recomputes and re-injects the billing plugin's prices table.
type BillingDSL struct {
// Active is the id of the profile in effect. Empty => the first profile.
Active string `yaml:"active,omitempty" json:"active,omitempty"`
Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"`
}
// BillingProfile is one named set of per-URL pricing rules.
type BillingProfile struct {
ID string `yaml:"id" json:"id"`
Label string `yaml:"label,omitempty" json:"label,omitempty"`
// Default applies when no rule matches: "free" or "unpriced".
Default string `yaml:"default,omitempty" json:"default,omitempty"`
Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"`
}
// BillingRule declares the pricing for everything under one URL.
type BillingRule struct {
// URL matches a source's base_url. "*" = any unmatched URL.
URL string `yaml:"url" json:"url"`
// Mode is one of: free, token, subscription, unpriced.
Mode string `yaml:"mode" json:"mode"`
Currency string `yaml:"currency,omitempty" json:"currency,omitempty"`
// Subscription is the flat monthly cost (for mode=subscription).
Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"`
// Models declares per-token prices when mode=token.
Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"`
// Free is true when mode=free (kept for explicitness).
// Peak applies to ALL token-priced requests under this URL.
Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"`
}
// BillingToken is the per-model price.
type BillingToken struct {
Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15"
Completion string `yaml:"completion" json:"completion"` // USD per million tokens
CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"`
// Reasoning is the USD-per-million rate for thinking tokens, for the rare
// provider that charges them differently from ordinary output. Omit it and
// reasoning is billed at Completion — which is what OpenAI, DeepSeek and
// Anthropic all do, and what every source on this deployment reports.
//
// It is a rate for a SUBSET of completion tokens, never an extra charge: the
// billing plugin applies only the difference between this and Completion, so
// setting it to a multiple of Completion reprices the thinking share rather
// than billing it twice.
Reasoning string `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
// LongContext is the prompt-price step above a token threshold. Nil = one
// flat rate, which is what a rule means when it says nothing.
LongContext *BillingLongContext `yaml:"long_context,omitempty" json:"long_context,omitempty"`
}
// BillingPeak declares peak-hour pricing.
type BillingPeak struct {
Multiplier float64 `yaml:"multiplier" json:"multiplier"`
// Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun)
Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"`
// Hours: each pair is [lo,hi] inclusive
Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"`
}
// BillingLongContext is a prompt-price step for long inputs.
//
// Every major provider charges the input tokens ABOVE a threshold at a higher
// rate — DeepSeek doubles past 128k, OpenAI and Anthropic step up as well. A
// flat prompt price therefore under-bills exactly the traffic an agent workload
// generates, and it under-bills it silently: the number is plausible, the bill
// adds up, and nothing says the rate was wrong.
//
// The step applies to the PROMPT only. Output and cache reads are billed at
// their own rates regardless of input length, which is how providers publish
// it — the higher rate is for the extra context you made the model read, not for
// what it wrote back.
type BillingLongContext struct {
// Above is the token count the step starts at. A request with exactly this
// many prompt tokens is still at the base rate; the step covers the tokens
// BEYOND it. Inclusive-below matches how the providers document it.
Above int `yaml:"above" json:"above"`
// Prompt is the USD-per-million rate for the tokens above Above.
Prompt string `yaml:"prompt" json:"prompt"`
// Multiplier is an alternative to Prompt: a factor applied to the base rate
// (DeepSeek's published step is exactly 2x). When both are set Prompt wins,
// and setting neither is a validation error rather than a silent no-op.
Multiplier float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
}
// Validate rejects a BillingDSL that could not work as written.
func (b *BillingDSL) Validate() error {
if b == nil {
return nil
}
ids := map[string]bool{}
for i := range b.Profiles {
p := &b.Profiles[i]
if p.ID == "" {
return fmt.Errorf("billing profile [%d] has no id", i)
}
if ids[p.ID] {
return fmt.Errorf("billing profile id %q appears more than once", p.ID)
}
ids[p.ID] = true
if p.Default != "" && p.Default != "free" && p.Default != "unpriced" {
return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default)
}
for j := range p.Rules {
r := &p.Rules[j]
if r.URL == "" {
return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j)
}
switch r.Mode {
case "free", "token", "subscription", "unpriced":
default:
return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode)
}
if r.Mode == "token" {
for m, t := range r.Models {
if t.Prompt == "" || t.Completion == "" {
return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m)
}
if _, err := ParseFloatUSDPerM(t.Prompt); err != nil {
return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err)
}
if _, err := ParseFloatUSDPerM(t.Completion); err != nil {
return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err)
}
}
}
}
}
// Active must resolve to a profile id.
if b.Active != "" && !ids[b.Active] {
return fmt.Errorf("billing.active = %q but no profile has that id", b.Active)
}
return nil
}
// Resolve returns the profile with the given id; empty id (or an id that does
// not exist) falls back to the FIRST profile. A config with profiles but no
// active marker should mean "use the one I wrote first" rather than "price
// nothing", which is what a nil here would silently do.
func (b *BillingDSL) Resolve(id string) *BillingProfile {
if b == nil || len(b.Profiles) == 0 {
return nil
}
if id != "" {
for i := range b.Profiles {
if b.Profiles[i].ID == id {
return &b.Profiles[i]
}
}
}
return &b.Profiles[0]
}
// parseFloatUSDPerM parses a USD-per-million price string. Returns the
// per-single-token rate the plugin expects (price / 1e6).
//
// Kept as a string in the DSL rather than a float: "0.15" survives round-trips
// through YAML editors, and a malformed value must fail VALIDATION (with the
// file and field named) rather than parse as 0 and under-bill silently — which
// is exactly the failure mode that motivated the DSL.
func ParseFloatUSDPerM(s string) (float64, error) {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0, fmt.Errorf("not a number: %q", s)
}
if f < 0 {
return 0, fmt.Errorf("negative price: %q", s)
}
return f / 1e6, nil
}
// KeyQuota is retained only to carry a scope entry's caps through the admin
// API. Quotas are per model, never per key: there is deliberately no key-wide
// total, so exhausting one model's budget never blocks the others.

View File

@ -52,9 +52,6 @@ sources:
if cfg.Sources[1].MaxConcurrent == 0 {
t.Fatal("default max_concurrent not applied")
}
if cfg.Sources[0].Models[0].Priority != 100 {
t.Fatalf("priority = %d", cfg.Sources[0].Models[0].Priority)
}
if cfg.DefaultModel != "AUTO" {
t.Fatalf("default model = %q", cfg.DefaultModel)
}

View File

@ -14,9 +14,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "deepseek",
MaxConcurrent: 8,
Models: []Model{
{ID: "deepseek-v4-flash", Priority: 100, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 90, Kind: "chat"},
{ID: "deepseek-v4-flash-vision-exp", Priority: 60, Kind: "chat"},
{ID: "deepseek-v4-flash", Kind: "chat"},
{ID: "deepseek-v4-pro", Kind: "chat"},
{ID: "deepseek-v4-flash-vision-exp", Kind: "chat"},
},
},
{
@ -25,10 +25,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "glm-5.3", Priority: 100, Kind: "chat"},
{ID: "glm-5.3-flash", Priority: 90, Kind: "chat"},
{ID: "glm-5.2", Priority: 80, Kind: "chat"},
{ID: "glm-5.1", Priority: 60, Kind: "chat"},
{ID: "glm-5.3", Kind: "chat"},
{ID: "glm-5.3-flash", Kind: "chat"},
{ID: "glm-5.2", Kind: "chat"},
{ID: "glm-5.1", Kind: "chat"},
},
},
{
@ -37,10 +37,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "kimi-k3", Priority: 100, Kind: "chat"},
{ID: "kimi-k2.7-code", Priority: 90, Kind: "chat"},
{ID: "kimi-k2.7-code-highspeed", Priority: 85, Kind: "chat"},
{ID: "kimi-k2.6", Priority: 70, Kind: "chat"},
{ID: "kimi-k3", Kind: "chat"},
{ID: "kimi-k2.7-code", Kind: "chat"},
{ID: "kimi-k2.7-code-highspeed", Kind: "chat"},
{ID: "kimi-k2.6", Kind: "chat"},
},
},
{
@ -49,11 +49,11 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "deepseek-ai/DeepSeek-V4-Pro", Priority: 100, Kind: "chat"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Priority: 90, Kind: "chat"},
{ID: "moonshotai/Kimi-K2.6", Priority: 80, Kind: "chat"},
{ID: "zai-org/GLM-5.1", Priority: 70, Kind: "chat"},
{ID: "Kwai-Kolors/Kolors", Priority: 50, Kind: "image"},
{ID: "deepseek-ai/DeepSeek-V4-Pro", Kind: "chat"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Kind: "chat"},
{ID: "moonshotai/Kimi-K2.6", Kind: "chat"},
{ID: "zai-org/GLM-5.1", Kind: "chat"},
{ID: "Kwai-Kolors/Kolors", Kind: "image"},
},
},
{
@ -62,9 +62,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 4,
Models: []Model{
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
{ID: "max-32k", Priority: 70, Kind: "chat"},
{ID: "4.0Ultra", Kind: "chat"},
{ID: "generalv3.5", Kind: "chat"},
{ID: "max-32k", Kind: "chat"},
},
},
{
@ -73,11 +73,11 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "qwen3.8-max", Priority: 100, Kind: "chat"},
{ID: "qwen3.8-flash", Priority: 90, Kind: "chat"},
{ID: "qwen3.7-plus", Priority: 85, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
{ID: "kimi-k3", Priority: 70, Kind: "chat"},
{ID: "qwen3.8-max", Kind: "chat"},
{ID: "qwen3.8-flash", Kind: "chat"},
{ID: "qwen3.7-plus", Kind: "chat"},
{ID: "deepseek-v4-pro", Kind: "chat"},
{ID: "kimi-k3", Kind: "chat"},
},
},
{
@ -86,9 +86,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "ernie-5.0-turbo", Priority: 100, Kind: "chat"},
{ID: "ernie-5.0", Priority: 90, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
{ID: "ernie-5.0-turbo", Kind: "chat"},
{ID: "ernie-5.0", Kind: "chat"},
{ID: "deepseek-v4-pro", Kind: "chat"},
},
},
{
@ -97,9 +97,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "doubao-seed-2.0-pro", Priority: 100, Kind: "chat"},
{ID: "doubao-seed-2.0", Priority: 90, Kind: "chat"},
{ID: "doubao-seed-2.0-flash", Priority: 80, Kind: "chat"},
{ID: "doubao-seed-2.0-pro", Kind: "chat"},
{ID: "doubao-seed-2.0", Kind: "chat"},
{ID: "doubao-seed-2.0-flash", Kind: "chat"},
},
},
{
@ -108,8 +108,8 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
{ID: "MiniMax-M3", Kind: "chat"},
{ID: "MiniMax-M2.5", Kind: "chat"},
},
},
// ── 国际主流 ────────────────────────────────────────────────────────────
@ -119,9 +119,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
{ID: "gpt-5.6-terra", Priority: 90, Kind: "chat"},
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
{ID: "gpt-5.6-sol", Kind: "chat"},
{ID: "gpt-5.6-terra", Kind: "chat"},
{ID: "gpt-5.6-luna", Kind: "chat"},
},
},
{
@ -130,10 +130,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "anthropic",
MaxConcurrent: 8,
Models: []Model{
{ID: "claude-opus-5", Priority: 100, Kind: "chat"},
{ID: "claude-fable-5", Priority: 95, Kind: "chat"},
{ID: "claude-sonnet-5", Priority: 85, Kind: "chat"},
{ID: "claude-haiku-4-5", Priority: 70, Kind: "chat"},
{ID: "claude-opus-5", Kind: "chat"},
{ID: "claude-fable-5", Kind: "chat"},
{ID: "claude-sonnet-5", Kind: "chat"},
{ID: "claude-haiku-4-5", Kind: "chat"},
},
},
{
@ -142,10 +142,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "gemini",
MaxConcurrent: 8,
Models: []Model{
{ID: "gemini-3.6-flash", Priority: 100, Kind: "chat"},
{ID: "gemini-3.5-flash", Priority: 90, Kind: "chat"},
{ID: "gemini-3.5-flash-lite", Priority: 80, Kind: "chat"},
{ID: "gemini-3.1-pro", Priority: 85, Kind: "chat"},
{ID: "gemini-3.6-flash", Kind: "chat"},
{ID: "gemini-3.5-flash", Kind: "chat"},
{ID: "gemini-3.5-flash-lite", Kind: "chat"},
{ID: "gemini-3.1-pro", Kind: "chat"},
},
},
{
@ -154,9 +154,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "groq",
MaxConcurrent: 8,
Models: []Model{
{ID: "openai/gpt-oss-120b", Priority: 100, Kind: "chat"},
{ID: "openai/gpt-oss-20b", Priority: 80, Kind: "chat"},
{ID: "groq/compound", Priority: 70, Kind: "chat"},
{ID: "openai/gpt-oss-120b", Kind: "chat"},
{ID: "openai/gpt-oss-20b", Kind: "chat"},
{ID: "groq/compound", Kind: "chat"},
},
},
{
@ -165,9 +165,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "mistral",
MaxConcurrent: 8,
Models: []Model{
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
{ID: "mistral-medium-latest", Kind: "chat"},
{ID: "mistral-large-latest", Kind: "chat"},
{ID: "codestral-latest", Kind: "chat"},
},
},
{
@ -180,10 +180,10 @@ var PresetTemplates = []SourceTemplate{
"X-Title": "ModelRouter",
},
Models: []Model{
{ID: "openrouter/auto", Priority: 100, Kind: "chat"},
{ID: "deepseek/deepseek-v4-flash", Priority: 90, Kind: "chat"},
{ID: "anthropic/claude-opus-5", Priority: 85, Kind: "chat"},
{ID: "openai/gpt-5.6-sol", Priority: 85, Kind: "chat"},
{ID: "openrouter/auto", Kind: "chat"},
{ID: "deepseek/deepseek-v4-flash", Kind: "chat"},
{ID: "anthropic/claude-opus-5", Kind: "chat"},
{ID: "openai/gpt-5.6-sol", Kind: "chat"},
},
},
{
@ -192,8 +192,8 @@ var PresetTemplates = []SourceTemplate{
Adapter: "github",
MaxConcurrent: 8,
Models: []Model{
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
{ID: "gpt-5.6-sol", Kind: "chat"},
{ID: "gpt-5.6-luna", Kind: "chat"},
},
},
// ── 本地模型 ────────────────────────────────────────────────────────────
@ -204,9 +204,9 @@ var PresetTemplates = []SourceTemplate{
Endpoint: "/api/chat",
MaxConcurrent: 4,
Models: []Model{
{ID: "qwen3", Priority: 80, Kind: "chat"},
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
{ID: "llama3.3", Priority: 70, Kind: "chat"},
{ID: "qwen3", Kind: "chat"},
{ID: "deepseek-r1", Kind: "chat"},
{ID: "llama3.3", Kind: "chat"},
},
},
}

View File

@ -0,0 +1,59 @@
package config
import (
"testing"
"time"
)
// ShouldRetry 是纯数据判据:状态码列表 + 响应体子串。它决定"这次失败值不值得
// 在网关内部等一会儿重试"。厂商知识绝不能进内核,所以判据必须能用一个表格测清楚。
func TestRetryPolicyShouldRetry(t *testing.T) {
cases := []struct {
name string
policy *RetryPolicy
status int
body string
want bool
}{
{"nil policy", nil, 500, "anything", false},
{"max_attempts=1 disables", &RetryPolicy{OnStatus: []int{400}, MaxAttempts: 1}, 400, "x", false},
{"status matches, no body filter",
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 429, "", true},
{"status mismatch",
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 400, "429", false},
{"status + body match",
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
400, `{"error_code":"TM.00001041"}`, true},
{"status matches but body does not",
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
400, `{"error_code":"TM.00001005"}`, false},
{"empty substring never matches",
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{""}, MaxAttempts: 3}, 400, "x", false},
{"any of several body signals",
&RetryPolicy{OnStatus: []int{400, 429}, OnBodyContains: []string{"TM.00001041", "TPM limit"}, MaxAttempts: 3},
429, `{"message":"TPM limit exceeded"}`, true},
}
for _, tc := range cases {
if got := tc.policy.ShouldRetry(tc.status, tc.body); got != tc.want {
t.Errorf("%s: ShouldRetry(%d, %q) = %v, want %v", tc.name, tc.status, tc.body, got, tc.want)
}
}
}
func TestRetryPolicyDelay(t *testing.T) {
cases := []struct {
name string
policy *RetryPolicy
want time.Duration
}{
{"nil", nil, 10 * time.Second},
{"explicit", &RetryPolicy{Delay: "250ms"}, 250 * time.Millisecond},
{"garbage falls back", &RetryPolicy{Delay: "not-a-duration"}, 10 * time.Second},
{"negative falls back", &RetryPolicy{Delay: "-5s"}, 10 * time.Second},
}
for _, tc := range cases {
if got := tc.policy.DelayDuration(); got != tc.want {
t.Errorf("%s: DelayDuration() = %v, want %v", tc.name, got, tc.want)
}
}
}

View File

@ -27,8 +27,9 @@ func NewGatewayKey() (string, error) {
// SecretBox encrypts and decrypts sensitive values (upstream API keys,
// custom header values, gateway keys) for persist-time protection. The
// master key comes from the LLMS_PROXY_MASTER_KEY environment variable
// (hex, 64 chars) or from a generated <runtime-dir>/master.key file.
// master key comes from the MODELROUTER_MASTER_KEY environment variable
// (hex, 64 chars; the pre-rename LLMS_PROXY_MASTER_KEY is still accepted) or
// from a generated <runtime-dir>/master.key file.
type SecretBox struct {
aead cipher.AEAD
}
@ -37,10 +38,18 @@ type SecretBox struct {
// file next to the runtime file. If neither exists, a fresh random key is
// generated and written to master.key with mode 0600.
func NewSecretBox(runtimeFile string) (*SecretBox, error) {
if v := os.Getenv("LLMS_PROXY_MASTER_KEY"); v != "" {
key, err := hex.DecodeString(strings.TrimSpace(v))
// New name first, then the pre-rename name: an existing unit file that still
// exports LLMS_PROXY_MASTER_KEY must keep starting after an upgrade.
envKey := os.Getenv("MODELROUTER_MASTER_KEY")
envName := "MODELROUTER_MASTER_KEY"
if envKey == "" {
envKey = os.Getenv("LLMS_PROXY_MASTER_KEY")
envName = "LLMS_PROXY_MASTER_KEY"
}
if envKey != "" {
key, err := hex.DecodeString(strings.TrimSpace(envKey))
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("LLMS_PROXY_MASTER_KEY: expected 64 hex chars")
return nil, fmt.Errorf("%s: expected 64 hex chars", envName)
}
return newBox(key)
}

View File

@ -1,102 +0,0 @@
package core
import (
"testing"
)
// account_adopt:把升级前的**非池**凭据接管进账号池。
//
// 为什么需要它:v2.0.x 之前登录只写一条 `channels:<plat>`,引入账号池后凭据落在
// `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 ——
// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。
//
// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 API**(读出的是明文,
// 写回时插件自己加密)。绝不要把保险库文件里的密文当明文喂给 API ——
// 保险库读取时会解密,那样会把凭据**二次加密**(实测踩过:1971 → 2965 字符,
// 该账号推理开始返回空)。
func TestAccountAdoptLegacyCredential(t *testing.T) {
_, ps := poolCore(t)
const legacy = `{"access_token":"LEGACY-TOK","refresh_token":"RT","nickname":"老账号"}`
// 造一条"旧版"非池凭据(模拟升级前留下的)
if _, err := ps.Call("channels", "__set_legacy_cred_probe", map[string]interface{}{
"platform": "buddy", "credential": legacy,
}); err != nil {
t.Fatalf("造旧凭据: %v", err)
}
// 接管进一个新池账号
m, err := ps.Call("channels", "account_adopt", map[string]interface{}{
"platform": "buddy", "account_id": "adopted1",
})
if err != nil {
t.Fatalf("account_adopt: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("接管失败: %#v", m)
}
// ① 池里必须出现该账号,且**有凭据**
lm, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
lj, _ := lm.(map[string]interface{})["json"].(map[string]interface{})
found := false
for _, a := range asArrInterface(lj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
found = true
if am["has_credential"] != true {
t.Errorf("接管后应 has_credential=true,实得 %v", am["has_credential"])
}
}
}
if !found {
t.Fatalf("接管后账号未出现在池里: %#v", lj["accounts"])
}
// ② 凭据必须是**原文**(未被二次加密)——这是本判据的核心
out, err := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:buddy:adopted1",
})
if err != nil {
t.Fatalf("读凭据: %v", err)
}
rj, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got, _ := rj["credential"].(string)
if got != legacy {
t.Errorf("接管后的凭据应与原文逐字节相同(不得二次加密)\n 期望: %s\n 实得: %s",
legacy, got)
}
if rj["len"] != nil {
if n, _ := rj["len"].(float64); int(n) != len(legacy) {
t.Errorf("凭据长度 %v,应为 %d(长度变化说明被二次加密)", n, len(legacy))
}
}
// ③ 补齐了展示字段(昵称从凭据里取)
for _, a := range asArrInterface(lj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" {
if am["nickname"] != "老账号" {
t.Errorf("昵称应从凭据补齐为「老账号」,实得 %v", am["nickname"])
}
}
}
// ④ 没有旧凭据时必须如实报错(不能假装成功)
_, _ = ps.Call("channels", "__clear_all_creds_probe", nil)
m2, _ := ps.Call("channels", "account_adopt", map[string]interface{}{
"platform": "buddy", "account_id": "adopted2",
})
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
if j2["ok"] == true {
t.Error("没有旧凭据时应如实失败,不能假装成功")
}
}
// asArrInterface 宽松取数组(插件桥回传的可能是 []interface{})。
func asArrInterface(v interface{}) []interface{} {
if a, ok := v.([]interface{}); ok {
return a
}
return nil
}

View File

@ -1,105 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// `account_test` 必须**真打一次上游**。
//
// 真实缺陷:此前它一个网络请求都不发 —— 只 syncAccountSource 后查内核源列表里
// 有没有那条记录,就返回 {ok:true, ms:1}。于是"测试账号"永远绿,包括凭据已失效、
// 被限流、上游宕机的情况。这正是「看着能用、一用就错」的假阳性,也正是用户报
// 「完全不可用」时最容易误导人的那种信号。
//
// 判据把三条都钉住:① 真发请求 ② 上游失败时必须 ok=false ③ 失败要区分
// "凭据失效"(需重新登录)与"暂时性失败"(可重试)—— 只说"失败"用户不知道该做什么。
func TestAccountTestReallyProbesUpstream(t *testing.T) {
var hits int32
fail := false
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
if fail {
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"error":"invalid_grant: token expired"}`))
return
}
body, _ := json.Marshal(map[string]interface{}{
"data": map[string]interface{}{"Response": map[string]interface{}{
"Data": map[string]interface{}{"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100},
}},
}},
})
_, _ = w.Write(body)
}))
defer ts.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
"credential": `{"access_token":"TOK"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
// ① 正常路径:必须真的打了上游
atomic.StoreInt32(&hits, 0)
m, err := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
})
if err != nil {
t.Fatalf("account_test: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j["ok"] != true {
t.Fatalf("正常凭据应 ok=true: %#v", j)
}
if j["probed"] != true {
t.Errorf("必须标记 probed=true 并真的发请求;实得 probed=%v", j["probed"])
}
if n := atomic.LoadInt32(&hits); n == 0 {
t.Fatal("account_test 未向上游发任何请求 —— 这就是「看着能用」的假阳性")
}
// ② 凭据失效:必须 ok=false,且归类为 auth(给出可操作提示)
fail = true
m2, _ := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "probeacct",
})
j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{})
if j2["ok"] != false {
t.Errorf("凭据失效时必须 ok=false,实得 %v", j2["ok"])
}
if j2["kind"] != "auth" {
t.Errorf("应归类为 auth(需重新登录),实得 %v", j2["kind"])
}
if s, _ := j2["action"].(string); s == "" {
t.Error("失败必须给出可操作提示(否则用户不知道要重新登录还是等一会)")
}
// ③ 无凭据的账号:如实报错,不假装成功
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "naked",
}); err != nil {
t.Fatalf("account_add(无凭据): %v", err)
}
m3, _ := ps.Call("channels", "account_test", map[string]interface{}{
"platform": "buddy", "account_id": "naked",
})
j3, _ := m3.(map[string]interface{})["json"].(map[string]interface{})
if j3["ok"] != false {
t.Errorf("无凭据时必须 ok=false,实得 %v", j3["ok"])
}
}

View File

@ -1,182 +0,0 @@
package core
import (
"net/url"
"regexp"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// callback 平台的授权 URL 是最容易"看起来对、实际不可用"的地方:URL 能拼出来、
// 页面能打开,但参数名/编码/主机错一处,上游就拒绝授权。
//
// 判据照参考实现(trae-oauth.ts / gemini-oauth.ts / lobsterai-oauth.ts)逐条锁定:
// - Trae 参数名必须是 auth_callback_url(不是 redirect_uri)
// - Gemini redirect_uri 主机必须是 localhost(不是 127.0.0.1),token 交换要一致
// - LobsterAI 的 login 在 **hash 段**(/portal#/login),三个参数用 ? 起
// - redirect_uri 必须百分号编码(含 :// 与 :)
func TestCallbackAuthorizeUrls(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
start := func(t *testing.T, plat string) (string, map[string]interface{}) {
t.Helper()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s start_login: %v", plat, err)
}
j := out.JSON.(map[string]interface{})
u, _ := j["url"].(string)
return u, j
}
t.Run("行1 trae 用 auth_callback_url 而非 redirect_uri", func(t *testing.T) {
u, _ := start(t, "trae")
if u == "" {
t.Fatal("trae 未给出授权 URL")
}
if !strings.Contains(u, "auth_callback_url=") {
t.Fatalf("trae 必须用 auth_callback_url(参考注释点名:错名则登录必失败): %s", u)
}
if strings.Contains(u, "redirect_uri=") {
t.Fatalf("trae 不该有 redirect_uri 参数: %s", u)
}
for _, want := range []string{"client_id=en1oxy7wnw8j9n", "auth_type=local", "login_channel=native_ide"} {
if !strings.Contains(u, want) {
t.Fatalf("trae 缺参数 %s: %s", want, u)
}
}
// 关键:不能再出现 "…/authorization&redirect_uri=" 这种坏的拼接
if strings.Contains(u, "authorization&") {
t.Fatalf("授权 URL 首参分隔符必须是 ? 而不是 &: %s", u)
}
// 参考 trae-oauth.ts 把参数集列为「一个都不能少」(早期只发 5 个参数,
// 登录页**永远停在授权中**,既不跳转也不回传)。逐条锁定:
// login_trace_id 是回调反查 pending 的唯一凭据;machine/device 身份除外
// 还可能被风控拦;x_* 是客户端形态伪装。
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("trae 授权 URL 无法解析: %v", err)
}
q := parsed.Query()
for _, k := range []string{
"login_version", "auth_from", "login_channel", "plugin_version",
"auth_type", "client_id", "redirect", "login_trace_id",
"auth_callback_url", "machine_id", "device_id",
"x_device_id", "x_machine_id", "x_device_brand", "x_device_type",
"x_os_version", "x_app_version", "x_app_type",
} {
if q.Get(k) == "" {
t.Fatalf("trae 缺参数 %s(参考列为必需): %s", k, u)
}
}
// 设备身份是 hex32,trace_id 是它们拼接后的末 16 位
m, d := q.Get("machine_id"), q.Get("device_id")
if len(m) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(m) {
t.Fatalf("machine_id 应为 hex32: %q", m)
}
if len(d) != 32 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(d) {
t.Fatalf("device_id 应为 hex32: %q", d)
}
if m == d {
t.Fatalf("machine_id 与 device_id 不应相同(同源会削弱设备身份)")
}
joined := m + d
if got := q.Get("login_trace_id"); got != joined[len(joined)-16:] {
t.Fatalf("login_trace_id 应为 (machine_id+device_id) 末16位,实得 %q", got)
}
// 同一平台两次登录必须得到不同设备身份(否则多账号共用设备,
// 会被上游以「该设备已签到」拦截)
u2, _ := start(t, "trae")
q2, _ := url.Parse(u2)
if q2.Query().Get("device_id") == d {
t.Fatalf("两次登录的 device_id 应不同(每账号独立设备身份)")
}
// Trae 注册的回调路径是 /authorize(不是插件的 /api/plugins/http 挂载点):
// 回环地址由「打开授权页的那台机器」解析,所以必须是本机 loopback 接收器。
if cb := q.Get("auth_callback_url"); cb != "" {
if !strings.HasSuffix(cb, "/authorize") {
t.Fatalf("trae auth_callback_url 必须以注册路径 /authorize 结尾,实得 %q", cb)
}
if !strings.HasPrefix(cb, "http://127.0.0.1:") {
t.Fatalf("trae auth_callback_url 应是 127.0.0.1 回环地址,实得 %q", cb)
}
}
})
t.Run("行2 gemini 主机是 localhost 且参数不重复", func(t *testing.T) {
u, _ := start(t, "gemini")
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("授权 URL 无法解析: %v", err)
}
ru := parsed.Query().Get("redirect_uri")
if !strings.HasPrefix(ru, "http://localhost:") {
t.Fatalf("gemini redirect_uri 主机必须是 localhost(参考注释明确): %q", ru)
}
// 路径必须是注册值 /oauth-callback(参考 GEMINI_CALLBACK_PATH)——
// 换成别的路径就是 redirect_uri_mismatch。
if !strings.HasSuffix(ru, "/oauth-callback") {
t.Fatalf("gemini redirect_uri 路径必须是 /oauth-callback,实得 %q", ru)
}
for _, k := range []string{"client_id", "response_type", "scope", "state"} {
if parsed.Query().Get(k) == "" {
t.Fatalf("gemini 缺参数 %s: %s", k, u)
}
}
// 缺 access_type=offline 拿不到 refresh_token;prompt=consent 保证每次都发。
if got := parsed.Query().Get("access_type"); got != "offline" {
t.Fatalf("gemini access_type 应为 offline(否则无 refresh_token),实得 %q", got)
}
if got := parsed.Query().Get("prompt"); got != "consent" {
t.Fatalf("gemini prompt 应为 consent,实得 %q", got)
}
// 参数不得重复(通用分支曾再追加一次 redirect_uri)
if n := strings.Count(u, "redirect_uri="); n != 1 {
t.Fatalf("redirect_uri 出现 %d 次(应恰好 1 次): %s", n, u)
}
if n := strings.Count(u, "client_id="); n != 1 {
t.Fatalf("client_id 出现 %d 次: %s", n, u)
}
})
t.Run("行3 lobsterai 的 login 在 hash 段且参数编码", func(t *testing.T) {
u, _ := start(t, "lobsterai")
if !strings.Contains(u, "/portal#/login?") {
t.Fatalf("lobsterai 授权页必须是 /portal#/login?…(login 在 hash 段): %s", u)
}
if strings.Contains(u, "/login&") {
t.Fatalf("首参分隔符必须是 ? 而不是 &: %s", u)
}
if !strings.Contains(u, "source=electron") {
t.Fatalf("lobsterai 缺 source=electron: %s", u)
}
// redirect_uri 必须编码(含 :// 与 :),否则登录页校验失败
if !strings.Contains(u, "redirect_uri=http%3A%2F%2F") {
t.Fatalf("lobsterai redirect_uri 必须百分号编码: %s", u)
}
})
t.Run("行4 端口用本机监听值而非硬编码8081", func(t *testing.T) {
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
u, _ := start(t, plat)
if strings.Contains(u, "127.0.0.1:8081") && plat != "trae" {
t.Fatalf("%s 不应硬编码 8081: %s", plat, u)
}
if strings.Contains(u, "{port}") {
t.Fatalf("%s 的 {port} 占位未被替换: %s", plat, u)
}
}
})
}

View File

@ -1,111 +0,0 @@
package core
import (
"strings"
"testing"
)
// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。
//
// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动
// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天**
// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也
// 不去领。
//
// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答**
// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数
// 「有几条能证明今天已被处理」。
func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) {
_, ps := poolCore(t)
// ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修)
out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
"refreshed": false,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["kind"] == nil {
t.Fatalf("探针应返回 kind,实得 %#v", out)
}
if j["covers_today"] != false {
t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"])
}
if j["kind"] == "already-claimed" {
t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)")
}
// ② 刷新后:正常报已领,且 covers_today=true
out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{
"refreshed": true,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if j2["covers_today"] != true {
t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"])
}
// ③ 记账判据必须**只看 covers_today**,不能用 kind 反推
//(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹)
cases := []struct {
name string
kind string
cov interface{}
want bool
}{
{"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false},
{"刷新后的 already-claimed 算已处理", "already-claimed", true, true},
{"success 算已处理", "success", true, true},
{"covers_today 缺省为 true", "claimed", nil, true},
{"inactive 不算已处理", "inactive", true, false},
{"failed 不算已处理", "failed", true, false},
}
for _, c := range cases {
o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{
"kind": c.kind, "covers_today": c.cov,
})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
oj, _ := o.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := oj["covers"].(bool); got != c.want {
t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got)
}
}
}
// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。
func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) {
_, ps := poolCore(t)
out, err := ps.Call("channels", "__schedule_probe", nil)
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
every, _ := j["auto_claim_every"].(float64)
if every <= 0 {
t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j)
}
// 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8),
// 一天只跑一次就必然有平台漏领。
if every >= 86400 {
t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every)
}
if j["first_due_now"] != true {
t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)")
}
// 已覆盖今天的平台必须靠记账跳过,否则会重复打上游
if !strings.Contains(toString(j["has_coverage_key"]), "true") {
t.Error("需要「今天已覆盖」的持久化记账键")
}
}
func toString(v interface{}) string {
if b, ok := v.(bool); ok {
if b {
return "true"
}
return "false"
}
return ""
}

View File

@ -1,159 +0,0 @@
package core
import (
"strings"
"testing"
)
// 自动签到的**能力表**必须与参考真值一致 —— 这张表是 auto_claim 的唯一依据,
// 错了就是"静默少领"或"每次调用都报自相矛盾的错"。
//
// 实测踩到的三处偏差(本轮修正):
//
// buddy 我们写 claim=true,但 buddy 既无 checkin 又无 claim_fn →
// 每次调用返回"不支持每日签到(平台未声明 checkin)"(表说支持、
// 函数说不支持)。参考里 buddy **没有** claim。
// raccoon 我们声明空表 → 白丢一个可领渠道。参考有:桌面端登录奖励
// POST /api/web/desktop/v1/login/points/grant(每号一次)。
// zcode 有签到,但每次领取现场产阿里云 captcha(headful Chromium,
// 按设备限流 150 次/小时)→ 参考把它列进 auto-checkin 的**排除表**。
// 自动跳过,手动仍可领。
func TestAutoClaimCapabilityTableMatchesReference(t *testing.T) {
c, ps := poolCore(t)
_ = c
// 参考真值(逐个 grep 参考仓的 claim 导出确认)
wantClaim := map[string]bool{
"trae": true, "minimax": true, "lobsterai": true, "qoder": true,
"loomy": true, "raccoon": true, "codearts": true,
"cline": false, "gemini": false, "zcode": true, "buddy": false,
}
// 自动签到必须跳过的(领取有代价)
wantAutoExclude := map[string]bool{"zcode": true}
for plat, want := range wantClaim {
t.Run("claim_"+plat, func(t *testing.T) {
got := creditsCapability(t, ps, plat)
if got == nil {
t.Fatalf("%s 无能力声明", plat)
}
if has := got["claim"] == true; has != want {
t.Errorf("%s claim = %v,参考为 %v", plat, has, want)
}
if got, ok := got["auto_exclude"].(bool); ok != wantAutoExclude[plat] {
t.Errorf("%s auto_exclude = %v,应为 %v", plat, got, wantAutoExclude[plat])
}
})
}
// 关键一致性:声称 claim=true 的平台**必须**真的可领
//(有 checkin 表或有 claim_fn)—— 否则就是 buddy 那种自相矛盾。
for plat, want := range wantClaim {
if !want {
continue
}
m, err := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("%s: %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
t.Fatalf("%s 无返回", plat)
}
if j["capable"] != true {
t.Errorf("%s 声称 claim=true 但不可领(%v)—— 会出现「表说支持、"+
"函数说不支持」的自相矛盾", plat, j["reason"])
}
}
// 反向:不声称 claim 的平台必须被判为不可领
m, _ := ps.Call("channels", "claims_capable", map[string]interface{}{"platform": "buddy"})
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j != nil && j["capable"] == true {
t.Error("buddy 不该被判为可领(它没有 claim)")
}
}
// 自动签到的**调度配置**必须覆盖两项需求:领取 + 模型获取。
//
// auto_claim 走调度器(不是 HTTP export),故断言调度声明。
func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
for _, want := range []struct{ name, needle string }{
{"每日领取", `fn = "auto_claim"`},
{"模型目录自动获取", `fn = "refresh_models"`},
{"凭据自动续期", `fn = "refresh_creds"`},
{"启动即补跑", `first_due_now = true`},
} {
if !strings.Contains(b, want.needle) {
t.Errorf("调度表缺 %s(%s)", want.name, want.needle)
}
}
// 模型刷新间隔照参考 MODEL_REFRESH_INTERVAL_MS(2 小时)
if !strings.Contains(b, `fn = "refresh_models", every = 7200`) {
t.Error("refresh_models 间隔应为 7200(参考 2h)")
}
// 按**行**判断(固定字符窗会溢到下一行,误报)
lineOf := func(fn string) string {
for _, ln := range strings.Split(b, "\n") {
if strings.Contains(ln, `fn = "`+fn+`"`) {
return ln
}
}
return ""
}
// auto_claim **现在**也 first_due_now,且间隔远小于一天。
//
// ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务,
// 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。
// 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次
// 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半:
// ① 频率提高 + first_due_now(任何时刻重启都能补上);
// ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**,
// 而不是靠"一天只跑一次"来碰巧避开)。
// 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。
if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") {
t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln)
}
// 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈)
for _, fn := range []string{"refresh_models", "refresh_creds"} {
ln := lineOf(fn)
if ln == "" {
continue
}
if !strings.Contains(ln, "first_due_now = true") {
t.Errorf("%s 应声明 first_due_now(参考点名的重启自愈缺陷):%s", fn, ln)
}
}
// auto_exclude 必须挡住 zcode(领取有代价:headful Chromium)
if !strings.Contains(b, "auto_exclude = true") {
t.Error("zcode 应标记 auto_exclude(照参考 isAutoCheckinExcluded)")
}
if i := strings.Index(b, `if cap.auto_exclude then`); i < 0 {
t.Error("auto_claim 必须尊重 auto_exclude(否则会拉起浏览器)")
}
}
// creditsCapability 读插件的能力表条目。
func creditsCapability(t *testing.T, ps interface {
Call(string, string, map[string]interface{}) (interface{}, error)
}, plat string) map[string]interface{} {
t.Helper()
m, err := ps.Call("channels", "credits_capability", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("credits_capability(%s): %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
return nil
}
if cap, ok := j["capability"].(map[string]interface{}); ok {
return cap
}
return nil
}

View File

@ -1,122 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)。
//
// 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
// 而**服务端扣哪个包不由我们决定** —— 插件能决定的是**用哪个号**:
// 优先用「还有 15 天内到期积分」的号(那部分不用就作废),只剩永久积分的号排最后;
// **锁定永久积分**时只剩永久积分的号等同于不可用。
//
// 判据用假余额上游,逐条钉住分档规则(含三个易错点)。
func TestBalanceTierSelection(t *testing.T) {
// 假 buddy 余额端点:返回三个包,覆盖三种情形
// pkg A: 有效 + 10 天内到期 + 剩余 100 → 临时桶
// pkg B: 有效 + 到期时间未知 + 剩余 50 → 永久桶(保守)
// pkg C: **已失效** + 剩余 9999 → 必须跳过(否则虚增可用额度)
future := nowMs() + 300*24*3600*1000 // 300 天后
soon := nowMs() + 10*24*3600*1000 // 10 天后
body := map[string]interface{}{
"data": map[string]interface{}{
"Response": map[string]interface{}{
"Data": map[string]interface{}{
"Accounts": []map[string]interface{}{
{"Status": 1, "CycleCapacityRemain": 100, "DeductionEndTime": soon},
{"Status": 1, "CycleCapacityRemain": 50, "DeductionEndTime": future},
{"Status": 2, "CycleCapacityRemain": 9999, "DeductionEndTime": future},
},
},
},
},
}
raw, _ := json.Marshal(body)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(raw)
}))
defer ts.Close()
_, ps := poolCore(t)
// 把一个 buddy 账号加进池,并把余额端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "tieracct",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
// ① 分档:临时=100(只有 10 天内那个),永久=50(未知/远期),失效包被跳过
out, err := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
if err != nil {
t.Fatalf("分档探针: %v", err)
}
tw, _ := out.(map[string]interface{})
tj, _ := tw["json"].(map[string]interface{})
if tj == nil {
t.Fatalf("无 json: %#v", out)
}
if tj["error"] != nil {
t.Fatalf("分档报错: %v", tj["error"])
}
if got, _ := tj["expiring"].(float64); got != 100 {
t.Errorf("临时余额应为 100(仅 10 天内到期的包),实得 %v", tj["expiring"])
}
if got, _ := tj["permanent"].(float64); got != 50 {
t.Errorf("永久余额应为 50(到期未知/远期),实得 %v(若为 10049 说明失效包没跳过)",
tj["permanent"])
}
// ② 档位判定:有临时积分 → "expiring"(最优先)
if tj["tier"] != "expiring" {
t.Errorf("有 15 天内到期积分时档位应为 expiring,实得 %v", tj["tier"])
}
// ③ 锁定永久积分:该账号**仍可用**(因为有临时积分)
if _, err := ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatalf("锁定探针: %v", err)
}
out2, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if tj2["usable"] != true {
t.Errorf("锁定永久积分时,有临时积分的号仍应可用,实得 usable=%v", tj2["usable"])
}
// ④ 窗口可被显式覆盖:把窗口设成 0 ⇒ 没有"临时积分" ⇒ 只剩永久 ⇒ 锁定后不可用
if _, err := ps.Call("channels", "__set_expiring_window_probe",
map[string]interface{}{"days": 0}); err != nil {
t.Fatalf("窗口探针: %v", err)
}
out3, _ := ps.Call("channels", "credits_tier_probe",
map[string]interface{}{"platform": "buddy", "account_id": "tieracct"})
tj3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
if got, _ := tj3["expiring"].(float64); got != 0 {
t.Errorf("窗口=0 时临时桶应为 0,实得 %v", tj3["expiring"])
}
if tj3["usable"] != false {
t.Errorf("窗口=0 且锁定永久积分时该号应不可用,实得 usable=%v", tj3["usable"])
}
}
// nowMs 返回当前毫秒时间戳(与插件里 DeductionEndTime 的口径一致)。
func nowMs() int64 { return time.Now().UnixMilli() }

View File

@ -1,220 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"llmsproxy/internal/lua"
)
// 三个 callback 平台的回调路径**各是注册值**,且校验规则不同:
//
// Trae /authorize 不带 state(token 直接放 query 里)
// 有道 /auth/callback 必须带 state
// Gemini /oauth-callback 必须带 state
//
// 路径写错 → 上游 redirect_uri_mismatch;校验写错 → 要么接不住回调、
// 要么被伪造回调塞入凭据。本测试打真上游形态(本地 loopback 接收器)。
func TestCallbackPlatformsLoopback(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
start := func(t *testing.T, plat string) (m map[string]interface{}, nonce, sid string) {
t.Helper()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s start_login: %v", plat, err)
}
m = out.JSON.(map[string]interface{})
s := m["session"].(map[string]interface{})
return m, s["nonce"].(string), s["id"].(string)
}
poll := func(t *testing.T, sid string) map[string]interface{} {
t.Helper()
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
return o.JSON.(map[string]interface{})
}
t.Run("行1 Trae 回调路径是 /authorize 且无 state 也能接住", func(t *testing.T) {
_, _, sid := start(t, "trae")
// 交换端点指到假上游
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"Token":"ACCESS-NEW"}`))
}))
defer ts.Close()
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae", "callback": map[string]interface{}{"exchange_url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 重新起会话,拿到带 loopback 的 redirect_uri
m, nonce, sid := start(t, "trae")
ru, _ := m["redirect_uri"].(string)
if !strings.HasSuffix(ru, "/authorize") {
t.Fatalf("trae 回调路径必须是 /authorize,实得 %q", ru)
}
// Trae 的回调**不带 state**,而是把 token 放 query 里
q := url.Values{}
q.Set("refreshToken", "RT-1")
q.Set("userInfo", `{"UserID":"u1","ScreenName":"张三"}`)
resp, err := http.Get(ru + "?" + q.Encode())
if err != nil {
t.Fatalf("打 loopback 接收器失败: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("Trae 回调(无 state)应被接住,实得 %d", resp.StatusCode)
}
st := waitState(t, poll, sid, "completed")
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, _ := ps.GetSecret("channels", "channels:trae")
if !ok {
t.Fatal("凭据未落库")
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
if cred["access_token"] != "ACCESS-NEW" {
t.Fatalf("凭据应是交换后的新 access,实得 %v", cred["access_token"])
}
if cred["uid"] != "u1" {
t.Fatalf("uid 应从 userInfo.UserID 取出(签到设备身份的种子), 实得 %v", cred["uid"])
}
_ = nonce
})
t.Run("行2 Gemini 路径是 /oauth-callback 且错 state 被拒", func(t *testing.T) {
m, nonce, sid := start(t, "gemini")
ru, _ := m["redirect_uri"].(string)
if !strings.HasSuffix(ru, "/oauth-callback") {
t.Fatalf("gemini 回调路径必须是 /oauth-callback(参考 GEMINI_CALLBACK_PATH),实得 %q", ru)
}
if !strings.HasPrefix(ru, "http://localhost:") {
t.Fatalf("gemini 回调主机必须是 localhost(参考注释明确),实得 %q", ru)
}
// 错 state 必须被接收器拒(400),且不推进会话
bad, err := http.Get(ru + "?code=C-1&state=WRONG")
if err != nil {
t.Fatal(err)
}
bad.Body.Close()
if bad.StatusCode != 400 {
t.Fatalf("state 不匹配应 400(防伪造回调),实得 %d", bad.StatusCode)
}
if st := poll(t, sid); st["state"] != "pending" {
t.Fatalf("错 state 不应推进会话,实得 %v", st)
}
// 正确 state → 完成(交换端点指到假上游)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
// 必须带 client_secret:只发 client_id 会 invalid_request,
// 症状正是「浏览器显示授权成功但账号不出现」
if r.PostFormValue("client_secret") == "" {
t.Error("token 交换必须带 client_secret")
}
if r.PostFormValue("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q", r.PostFormValue("grant_type"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"GA-1","refresh_token":"GR-1","expires_in":3600}`))
}))
defer ts.Close()
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "gemini",
"callback": map[string]interface{}{"token_url": ts.URL, "mode": "code"},
}); err != nil {
t.Fatal(err)
}
good, err := http.Get(ru + "?code=C-1&state=" + nonce)
if err != nil {
t.Fatal(err)
}
good.Body.Close()
st := waitState(t, poll, sid, "completed")
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, _ := ps.GetSecret("channels", "channels:gemini")
if !ok {
t.Fatal("凭据未落库")
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
if cred["access_token"] != "GA-1" {
t.Fatalf("access_token = %v", cred["access_token"])
}
})
}
// waitState 轮询到目标状态或超时(loopback 写文件是异步的,需要一拍)。
func waitState(t *testing.T, poll func(*testing.T, string) map[string]interface{},
sid, want string) map[string]interface{} {
t.Helper()
var st map[string]interface{}
for i := 0; i < 30; i++ {
st = poll(t, sid)
if st["state"] != "pending" {
return st
}
time.Sleep(100 * time.Millisecond)
}
return st
}
// 占位符未替换检查:授权 URL 里残留 {xxx} 会让上游收到字面量占位符
// (而且往往不报错,只是行为诡异)。三个 callback 平台逐个验。
func TestAuthorizeURLsHaveNoUnfilledPlaceholders(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
for _, plat := range []string{"trae", "gemini", "lobsterai"} {
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"` + plat + `"}`, Role: "admin",
})
if err != nil {
t.Fatalf("%s: %v", plat, err)
}
m := out.JSON.(map[string]interface{})
for _, key := range []string{"url", "redirect_uri"} {
v, _ := m[key].(string)
if v == "" {
continue
}
if strings.Contains(v, "{") || strings.Contains(v, "nil") {
t.Errorf("%s 的 %s 仍有未替换占位符: %s", plat, key, v)
}
}
}
}

View File

@ -1,55 +0,0 @@
package core
import (
"os"
"testing"
)
// 用**真实的 models.dev 响应**验证解析口径(5.6MB / 229 键 / opencode 122 模型)。
//
// 为什么必须用真数据:字段路径(modalities.input / cost.input / limit.context)
// 是我照参考注释写的,只有真数据能证明写对了。合成样本会把这个验证变成自说自话。
//
// 样本不在仓库里(5.6MB),缺失时跳过 —— 但 CI 里能拉到就一定会跑。
func TestCapabilityParseAgainstRealModelsDev(t *testing.T) {
b, err := os.ReadFile("/tmp/modelsdev.json")
if err != nil {
t.Skip("无 models.dev 样本(/tmp/modelsdev.json),跳过")
}
_, ps := poolCore(t)
out, err := ps.Call("channels", "__parse_capability_probe", map[string]interface{}{
"body": string(b), "key": "opencode",
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["error"] != nil {
t.Fatalf("解析失败: %v", j["error"])
}
total, _ := j["total"].(float64)
free, _ := j["free"].(float64)
image, _ := j["image"].(float64)
ctx, _ := j["with_ctx"].(float64)
t.Logf("opencode: total=%.0f free=%.0f image=%.0f with_ctx=%.0f", total, free, image, ctx)
// ① 规模:实测 122 个模型(允许上游增减,给个合理区间)
if total < 50 {
t.Errorf("解析出的模型数太少(%.0f)—— 字段路径可能没对上真实结构", total)
}
// ② 免费判定必须有命中的(实测有若干 cost 全 0 的模型)
if free == 0 {
t.Error("一个免费模型都没识别出来 —— cost 判定路径可能错了")
}
if free >= total {
t.Error("全部被标免费 —— cost 判定过于宽松")
}
// ③ 多模态也要有命中的(gpt-5.4 之类含 image)
if image == 0 {
t.Error("没识别出任何多模态模型 —— modalities 路径可能错了")
}
// ④ 上下文窗口要能取到(limit.context)
if ctx == 0 {
t.Error("没取到任何上下文窗口 —— limit 路径可能错了")
}
}

View File

@ -1,86 +0,0 @@
package core
import (
"testing"
)
// 模型能力元数据(照参考 opencode-capability)。
//
// 关键纪律:`/zen/v1/models` 只返回 4 个字段、**不含能力信息**;能力在 models.dev
// (实测 5.05 MB / 下载 1.4s)。参考的真机事故:最初在模型列表渲染路径上直接
// 等这个下载,模型选择器要等 1.4s+ 才出列表 —— 用户报障「点开是一片空白」。
//
// ⇒ 三条硬约定:渲染路径只读同步缓存;磁盘缓存冷启动命中;拉取在后台。
// 我们额外:本插件是服务端进程,但 5MB 仍不能放请求路径上 ⇒ 挂定时任务。
//
// 本判据钉住"非阻塞"这一条:**缓存未就绪时 model_list 必须立刻返回**,
// 而不是去等网络。
func TestCapabilityReadNeverBlocks(t *testing.T) {
_, ps := poolCore(t)
// 清掉能力缓存(模拟冷启动)
if _, err := ps.Call("channels", "__clear_capabilities_probe", nil); err != nil {
t.Fatalf("清缓存: %v", err)
}
// 冷启动下取模型列表:必须成功返回(退回兜底 isFree),且不得等待网络。
// 判据:能返回就说明没阻塞(真去下 5MB 会超时/极慢)。
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("冷启动下 model_list 必须成功(退回兜底),实得 %#v", m)
}
models := asArrInterface(j["models"])
if len(models) == 0 {
t.Fatal("冷启动下仍应列出兜底模型")
}
// 冷启动时 isFree 应退回声明的 models_free(不能凭空说全部免费)
sawFree := false
for _, it := range models {
if mm, ok := it.(map[string]interface{}); ok && mm["isFree"] == true {
sawFree = true
}
}
if !sawFree {
t.Error("冷启动时 opencode 应有 free 模型(退回声明的 models_free)")
}
}
// 能力元数据一旦就绪,isFree 必须改由它决定(cost 权威,而非快照)。
func TestCapabilityOverridesFreeFlag(t *testing.T) {
_, ps := poolCore(t)
// 注入一份假能力:把某个原本标 free 的模型改成收费、把非 free 的改成免费
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
"platform": "opencode",
"model_id": "big-pickle",
"is_free": false, // 原本声明 free,能力表说收费 ⇒ 必须以能力表为准
}); err != nil {
t.Fatalf("注入: %v", err)
}
if _, err := ps.Call("channels", "__inject_capability_probe", map[string]interface{}{
"platform": "opencode",
"model_id": "glm-5.2",
"is_free": true, // 原本不在 free 列表,能力表说免费
}); err != nil {
t.Fatalf("注入: %v", err)
}
m, _ := ps.Call("channels", "model_list", map[string]interface{}{"platform": "opencode"})
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
if got["big-pickle"] {
t.Error("能力表说收费时 isFree 必须为 false(权威来源是 cost,不是快照)")
}
if !got["glm-5.2"] {
t.Error("能力表说免费时 isFree 必须为 true")
}
}

View File

@ -1,91 +0,0 @@
package core
import (
"strings"
"testing"
)
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
//
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
//
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
//
// HTTP 429
// {"error":{"code":"INFERENCE_CAP_ERROR",
// "message":"Error 429: Daily free limit reached on model
// deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
func TestClineAdapterParsesProseWait(t *testing.T) {
c, _ := poolCore(t)
vm := c.VM()
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
out, ok, err := vm.TransformError("channels-cline", 429, body)
if err != nil || !ok {
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
}
if !strings.Contains(out, "额度已用完") {
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
}
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
if !strings.Contains(out, "19") {
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
}
if !strings.Contains(out, "重试无用") {
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
}
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
}
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
acc, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
if !strings.Contains(acc, "1 小时 30 分") {
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
}
// 秒级也要能解析(否则秒级限流会退化成通用文案)
secs, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
}
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
out402, _, _ := vm.TransformError("channels-cline", 402,
`{"error":{"message":"Insufficient credits"}}`)
if !strings.Contains(out402, "充值") {
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
}
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
out429, _, _ := vm.TransformError("channels-cline", 429,
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
if strings.Contains(out429, "额度已用完") {
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
}
if !strings.Contains(out429, "限流") {
t.Errorf("普通 429 应说成限流,实得 %q", out429)
}
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
//(只认一种会在别的形态下静默丢掉整条信息)
for _, shell := range []string{
`{"error":"Daily free limit reached. Try again in 1h"}`,
`{"msg":"Daily free limit reached. Try again in 1h"}`,
`Daily free limit reached. Try again in 1h`,
} {
o, _, _ := vm.TransformError("channels-cline", 429, shell)
if !strings.Contains(o, "额度已用完") {
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
}
}
}

View File

@ -1,171 +0,0 @@
package core
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
// CodeArts 专用适配器是通过**插件自装**(__llmsproxy_adapters.install)
// 引入的,不是内置适配器 —— 因为 AK/SK 签名属厂商知识,内置里出现 codearts
// 就破了"内核零厂商名"纪律。
//
// 这组判据要证明三件事:
// 1. 插件加载时确实把适配器装上了
// 2. 装上之后,源能引用它(不是"装了但引用不到")
// 3. 它产出的签名是**真的能验**(而不是拼出个像签名的字符串)
func TestCodeArtsAdapterInstalledByPlugin(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// ① 适配器必须在插件加载后就位
adapters := c.ListAdapters()
found := false
for _, a := range adapters {
if a.Name == "codearts" {
found = true
}
}
if !found {
t.Fatalf("插件未装 codearts 适配器;已装: %#v", adapters)
}
// ② 归属必须是 channels 插件(证明是它装的,不是内置)
if owner := ps.AdapterOwnerOf("codearts"); owner != "channels" {
t.Errorf("适配器归属应为 channels,实得 %q", owner)
}
// ③ 签名必须真的可验:用 Go 标准库按 SDK-HMAC-SHA256 独立复算
got, err := ps.Call("channels", "__codearts_adapter_sign_probe", map[string]interface{}{
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/api/v2/chat/completions",
"method": "POST",
"body": `{"model":"CodeArts-Pangu"}`,
"api_key": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST","security_token":"STTEST"}`,
})
if err != nil {
t.Fatalf("签名探针: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("探针无 json: %#v", got)
}
if m["error"] != nil {
t.Fatalf("探针报错: %v", m["error"])
}
auth, _ := m["authorization"].(string)
if !strings.HasPrefix(auth, "SDK-HMAC-SHA256 Access=AKTEST,SignedHeaders=") {
t.Fatalf("Authorization 形态不符(参考要求 Access= 不是 Credential=): %s", auth)
}
if !strings.Contains(auth, "Signature=") {
t.Fatalf("Authorization 缺 Signature: %s", auth)
}
// 签名后追加的头必须在,且不参与 canonical
for _, k := range []string{"Agent-Type", "X-Language"} {
if m["hdr_"+k] == nil || m["hdr_"+k] == "" {
t.Errorf("缺签名后追加的头 %s", k)
}
}
if m["hdr_Agent-Type"] != "PromptCenter" {
t.Errorf("Agent-Type = %v,应为 PromptCenter", m["hdr_Agent-Type"])
}
// canonical 不得包含签名后追加的头(参考注释点名:它们是签名后加的)
canon, _ := m["canonical"].(string)
if strings.Contains(canon, "agent-type") || strings.Contains(canon, "x-language") {
t.Errorf("canonical 不应含签名后追加的头:\n%s", canon)
}
// Authorization 不得进 canonical
if strings.Contains(canon, "authorization") {
t.Errorf("canonical 不应含 authorization:\n%s", canon)
}
// x-sdk-content-sha256 说明 payload hash 参与了签名
if !strings.Contains(canon, "x-sdk-content-sha256") {
t.Errorf("canonical 应含 x-sdk-content-sha256:\n%s", canon)
}
// URI 必须以 / 结尾(参考点名的易错点)
if !strings.Contains(canon, "/api/v2/chat/completions/\n") {
t.Errorf("canonical 的 URI 未补尾斜杠(参考点名的易错点):\n%s", canon)
}
// ④ **独立验签**:用 Go 标准库按 SDK-HMAC-SHA256 从 canonical 复算签名,
// 必须与适配器给出的 Signature 逐字节相同。
//
// 为什么必须有这一步:前面几条只验证"形态正确"(Access=、SignedHeaders、
// 尾斜杠)。签名算法本身写错(HMAC 输入少一段、canonical 分隔符用错)时
// 形态依然全对,但上游会 401。复算才是真正的判据。
sts, _ := m["string_to_sign"].(string)
if sts == "" {
t.Fatal("探针未带出 string_to_sign(判据必须比对真实函数算出的中间值)")
}
// string_to_sign = "SDK-HMAC-SHA256\n" + date + "\n" + sha256(canonical)
parts := strings.SplitN(sts, "\n", 3)
if len(parts) != 3 || parts[0] != "SDK-HMAC-SHA256" {
t.Fatalf("string_to_sign 头段不符: %q", sts)
}
canonSum := sha256.Sum256([]byte(canon))
if parts[2] != hex.EncodeToString(canonSum[:]) {
t.Errorf("string_to_sign 的 canonical 摘要不符\n算得: %s\n实得: %s",
hex.EncodeToString(canonSum[:]), parts[2])
}
mac := hmac.New(sha256.New, []byte("SKTEST"))
mac.Write([]byte(sts))
wantSig := hex.EncodeToString(mac.Sum(nil))
gotSig := auth[strings.Index(auth, "Signature=")+len("Signature="):]
if gotSig != wantSig {
t.Errorf("签名独立复算不符(算法写错?)\n复算: %s\n实得: %s", wantSig, gotSig)
}
b, _ := json.Marshal(m)
t.Logf("签名探针输出: %s", string(b))
}
// 源注册必须真的引用插件装的 codearts 适配器 —— "装了但源还指向 openai"
// 是这类改动最可能的漏配,且不会报错(只是签名不生效、上游 401)。
func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
// ⚠️ 必须用 poolCore:它给 channels 授予了 add_source/list_sources 等
// 内核权限。用 kernelTestCore 裸加载插件时 add_source 会被拒
//("kernel action add_source is not granted to plugin channels"),
// 而这个拒绝**不报错、只是不出源** —— 我就在这里绕了一圈才看出根因。
c, ps := poolCore(t)
// 建一个 codearts 账号 → 触发源注册
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts",
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
names := []string{}
for _, x := range srcs {
if n, _ := x["name"].(string); n != "" {
names = append(names, n)
}
}
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
}
if ad, _ := sm["adapter"].(string); ad != "codearts" {
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)
}
bu, _ := sm["base_url"].(string)
if !strings.Contains(bu, "snap-access") {
t.Errorf("base_url 不符: %q", bu)
}
}

View File

@ -1,186 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// CodeArts 端到端:起真实回调服务器 → 模上游回 code → 走真实 STS 交换
// (带 DPoP)→ 凭据落盘。判据检查**交换请求本身的形态**(DPoP 头、
// grant_type、code_verifier、redirect_uri 端口),因为只断言"存了个 token"
// 无法防止把 OAuth 悄悄换回手填。
func TestCodeArtsOAuthEndToEnd(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 假 STS 上游:校验请求形态,返回 credentials + refresh_token
var gotForm url.Values
var gotDpop string
sts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotDpop = r.Header.Get("DPoP")
_ = r.ParseForm()
gotForm = r.PostForm
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"credentials": map[string]interface{}{
"access_key_id": "AKTEST",
"secret_access_key": "SKTEST",
"security_token": "STTEST",
"expiration": "2026-12-31T00:00:00Z",
},
"refresh_token": "RT-TEST",
})
}))
defer sts.Close()
// 把 STS 端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"codearts_sts": map[string]interface{}{"url": sts.URL},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
// 1) start_login → 拿回调地址与登录 URL
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if m["error"] != nil {
t.Fatalf("start_login 报错: %v", m["error"])
}
loginURL, _ := m["url"].(string)
redirectURI, _ := m["redirect_uri"].(string)
sess := m["session"].(map[string]interface{})
sid, _ := sess["id"].(string)
t.Logf("login_url = %s", loginURL)
t.Logf("redirect_uri = %s", redirectURI)
// 2) 登录 URL 必须逐项符合参考 buildOAuthLoginUrl
if !strings.HasPrefix(loginURL, "https://codearts.huaweicloud.com/portal/authorize?") {
t.Fatalf("authorize base 不对: %s", loginURL)
}
for _, want := range []string{
"client_id=codearts-agent",
"uri_scheme=codearts-agent",
"code_challenge_method=SHA-256", // 不是 S256
"plugin-name=snap_AIIDE",
"plugin-version=5.2.0",
"theme=2",
"locale=zh-cn",
} {
if !strings.Contains(loginURL, want) {
t.Errorf("authorize URL 缺 %q\n实际: %s", want, loginURL)
}
}
// 反向:**不得**含 auth_callback_url(参考注释:会被 portal 判异常并回退旧流程)
if strings.Contains(loginURL, "auth_callback_url") {
t.Errorf("authorize URL 不应含 auth_callback_url(会导致 portal 回退旧流程): %s", loginURL)
}
// code_challenge 必须存在且非空
if !strings.Contains(loginURL, "code_challenge=") ||
strings.Contains(loginURL, "code_challenge=&") {
t.Errorf("缺 code_challenge: %s", loginURL)
}
// 3) 模拟上游重定向到回调(带 code)
u, err := url.Parse(redirectURI)
if err != nil {
t.Fatal(err)
}
q := u.Query()
q.Set("code", "AUTHCODE-TEST")
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
t.Fatalf("打回调: %v", err)
}
resp.Body.Close()
// 4) poll_login → 触发真实 STS 交换
pout, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatalf("poll_login: %v", err)
}
pm := pout.JSON.(map[string]interface{})
if pm["state"] != "completed" && pm["state"] != "success" && pm["state"] != "done" {
t.Fatalf("poll_login 未成功: %#v", pm)
}
// 5) 交换请求形态(判据的核心)
if gotDpop == "" {
t.Fatal("STS 请求缺 DPoP 头 —— 不是新式 OAuth")
}
if strings.Count(gotDpop, ".") != 2 {
t.Errorf("DPoP 应是三段 JWS,实得 %q", gotDpop)
}
if gotForm.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type = %q,应为 authorization_code", gotForm.Get("grant_type"))
}
if gotForm.Get("client_id") != "codearts-agent" {
t.Errorf("client_id = %q", gotForm.Get("client_id"))
}
if gotForm.Get("code_verifier") == "" {
t.Error("缺 code_verifier —— PKCE 没生效")
}
if gotForm.Get("code") != "AUTHCODE-TEST" {
t.Errorf("code = %q", gotForm.Get("code"))
}
ruWant := "http://127.0.0.1" + u.Port() + "/oauth/callback"
if !strings.HasPrefix(redirectURI, ruWant[:len("http://127.0.0.1")]) {
t.Errorf("redirect_uri 主机不对: %s", redirectURI)
}
if gotForm.Get("redirect_uri") != redirectURI {
t.Errorf("redirect_uri = %q,应为回调地址 %q", gotForm.Get("redirect_uri"), redirectURI)
}
// 6) 凭据必须含续期材料(refresh_token / code_verifier / DPoP 私钥 JWK)
// 凭据必须含**续期材料** —— 这是 OAuth 流程存在的意义。
// 只断言"登录成功"无法防止凭据被截断成裸 AK/SK(那样 refresh 就废了)。
raw, err := ps.Call("channels", "__codearts_cred_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("凭据探针: %v", err)
}
w, _ := raw.(map[string]interface{})
cj, _ := w["json"].(map[string]interface{})
if cj == nil {
t.Fatalf("探针无 json: %#v", raw)
}
if cj["error"] != nil {
t.Fatalf("凭据未落盘: %v (refs=%v)", cj["error"], cj["refs"])
}
t.Logf("凭据 ref=%v access_key_id=%v", cj["ref"], cj["access_key_id"])
if cj["access_key_id"] != "AKTEST" {
t.Errorf("access_key_id = %v,应为 AKTEST(STS 响应未正确解析)", cj["access_key_id"])
}
for _, k := range []string{"refresh_token", "code_verifier", "dpop_private_key_jwk"} {
if cj[k] == nil || cj[k] == "" {
t.Errorf("凭据缺 %s(无法静默续期)", k)
}
}
if cj["refresh_token"] != "RT-TEST" {
t.Errorf("refresh_token = %v,应为 RT-TEST", cj["refresh_token"])
}
_ = json.Marshal
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
Method: "POST", Body: `{"session_id":"` + sid + `"}`, Role: "admin",
})
}

View File

@ -1,112 +0,0 @@
package core
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
)
// CodeArts 的 DPoP 证明是标准 ES256 JWS。用 Go 标准库(crypto/ecdsa)当权威
// 校验器验它 —— 只断言"能拼出字符串"是不够的:JWS 的 ES256 签名必须是
// **raw r||s(各 32 字节)**,而 openssl 默认输出 DER;写错格式服务端会拒,
// 而字符串看起来完全正常(这是最容易蒙混过去的一类缺陷)。
func TestCodeArtsDpopJwsVerifiesAsES256(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
got, err := ps.Call("channels", "__codearts_dpop_probe",
map[string]interface{}{"htm": "POST", "htu": "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens"})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["error"] != nil {
t.Fatalf("探针报错: %v", m["error"])
}
jws, _ := m["jws"].(string)
if jws == "" {
t.Fatal("jws 为空")
}
parts := strings.Split(jws, ".")
if len(parts) != 3 {
t.Fatalf("JWS 应为三段,实得 %d 段", len(parts))
}
dec := func(s string) []byte {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
t.Fatalf("base64url 解码失败: %v", err)
}
return b
}
// ---- header ----
var hdr map[string]interface{}
if err := json.Unmarshal(dec(parts[0]), &hdr); err != nil {
t.Fatalf("header 不是 JSON: %v", err)
}
if hdr["alg"] != "ES256" {
t.Errorf("alg 应为 ES256,实得 %v", hdr["alg"])
}
if hdr["typ"] != "dpop+jwt" {
t.Errorf("typ 应为 dpop+jwt,实得 %v", hdr["typ"])
}
jwk, _ := hdr["jwk"].(map[string]interface{})
if jwk == nil {
t.Fatal("header 必须内嵌 jwk(DPoP 要求公钥随证明走)")
}
// ---- 用 jwk 重建公钥 ----
xb, err := base64.RawURLEncoding.DecodeString(jwk["x"].(string))
if err != nil {
t.Fatal(err)
}
yb, err := base64.RawURLEncoding.DecodeString(jwk["y"].(string))
if err != nil {
t.Fatal(err)
}
if len(xb) != 32 || len(yb) != 32 {
t.Fatalf("P-256 的 x/y 应各 32 字节,实得 %d/%d", len(xb), len(yb))
}
pub := &ecdsa.PublicKey{Curve: elliptic.P256(),
X: new(big.Int).SetBytes(xb), Y: new(big.Int).SetBytes(yb)}
// ---- payload ----
var pl map[string]interface{}
if err := json.Unmarshal(dec(parts[1]), &pl); err != nil {
t.Fatalf("payload 不是 JSON: %v", err)
}
for _, k := range []string{"htm", "htu", "iat", "jti"} {
if _, ok := pl[k]; !ok {
t.Errorf("payload 缺 %s(照参考 signDpopJws)", k)
}
}
if pl["htm"] != "POST" {
t.Errorf("htm = %v", pl["htm"])
}
// ---- 验签(关键:raw r||s,不是 DER)----
sig := dec(parts[2])
if len(sig) != 64 {
t.Fatalf("ES256 签名必须是 64 字节 raw r||s,实得 %d 字节(若是 70-72 字节,说明漏了 DER→raw 转换)", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(pub, digest[:], r, s) {
t.Fatal("ES256 验签失败 —— 签名内容或格式不对,服务端会拒绝这个 DPoP")
}
}

View File

@ -1,65 +0,0 @@
package core
import (
"fmt"
"testing"
"llmsproxy/internal/lua"
)
// CodeArts 的 portal 拒绝 <10000 的回调端口(参考 listenOnCallbackPort 的
// MIN_CALLBACK_PORT = 10000 及 `assigned >= MIN_CALLBACK_PORT` 校验)。
// 判据直接量**实际分配的端口**,不复用参考常量做断言来源。
func TestCodeArtsLoopbackPortAtLeast10000(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"codearts"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m, _ := out.JSON.(map[string]interface{})
if m == nil {
t.Fatalf("无 JSON: %#v", out)
}
if m["error"] != nil {
t.Fatalf("报错: %v", m["error"])
}
ru, _ := m["redirect_uri"].(string)
if ru == "" {
t.Fatalf("无 redirect_uri: %#v", m)
}
t.Logf("redirect_uri = %s", ru)
var port int
if _, err := fmt.Sscanf(ru, "http://127.0.0.1:%d/", &port); err != nil {
if _, err2 := fmt.Sscanf(ru, "http://localhost:%d/", &port); err2 != nil {
t.Fatalf("解析端口失败: %v / %v (%s)", err, err2, ru)
}
}
if port < 10000 {
t.Fatalf("回调端口 %d < 10000 —— portal 会拒绝(参考 MIN_CALLBACK_PORT)", port)
}
// 回调路径也必须是注册值 /oauth/callback(参考 REDIRECT_PATH)
if !hasSuffix(ru, "/oauth/callback") {
t.Fatalf("回调路径应为 /oauth/callback,实得 %s", ru)
}
if s, _ := m["session"].(map[string]interface{}); s != nil {
if id, _ := s["id"].(string); id != "" {
ps.InvokeHandler("channels", "stop_login", &lua.HandlerRequest{
Method: "POST", Body: `{"session_id":"` + id + `"}`, Role: "admin",
})
}
}
}
func hasSuffix(s, suf string) bool {
return len(s) >= len(suf) && s[len(s)-len(suf):] == suf
}

View File

@ -1,204 +0,0 @@
package core
import (
"testing"
)
// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。
//
// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住:
//
// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容),
// 只在它"独占一句话成分"时才算阳性;
// - rate limit / quota / unauthor / forbidden 一律**不是**下架;
// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案
// `model expired-v3 does not exist`;
// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。
func TestDeadModelGoneJudgement(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
cases := []struct {
msg string
want bool
why string
}{
// ── 阳性:真的是下架 ──
{"model not found", true, "经典文案"},
{"unknown model: foo", true, "unknown model"},
{"no such model", true, "no such model"},
{"The model does not exist", true, "does not exist"},
{"error: model_not_found", true, "独占一句话(行尾)"},
{"model_not_found.", true, "后面是句末标点"},
// ── 阴性:看着像但**不是**下架 ──
{"error_class=model_not_found, retry allowed", false,
"字段名后面还有内容(初版误判下架,实测教训)"},
{"the model_not_found metric was not exported", false,
"作为指标名出现(实测教训)"},
{"rate limit exceeded for model foo", false, "限流换时段就能用"},
{"quota exceeded", false, "额度问题"},
{"insufficient balance", false, "余额问题"},
{"401 unauthorized", false, "认证问题"},
{"invalid api key", false, "认证问题"},
{"token expired", false, "token 过期(带主语)"},
// ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE,
// 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。
{"model expired-v3 does not exist", true,
"expired 无主语 → 不算认证失败;does not exist 才是真判据"},
// ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面):
// 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。
// 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是
// NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表),
// 一旦命中排除词就直接 false、不再看后面的不存在语义。
// 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。
{"the model lease expired", false, "租约/认证问题,不是下架"},
{"the model lease expired and does not exist", false,
"参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"},
// available/supported 不算(换套餐就能恢复)
{"model not available in your region", false,
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
{"", false, "空串"},
}
for _, tc := range cases {
out, err := ps.Call("channels", "__is_model_gone_probe",
map[string]interface{}{"message": tc.msg})
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
got, _ := j["gone"].(bool)
if got != tc.want {
t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why)
}
}
}
// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。
func TestDeadModelRecordAndPrune(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
before := modelIDsFor(t, ps, "zcode")
if len(before) == 0 {
t.Fatal("zcode 应有兜底模型")
}
victim := before[0]
// 记录一条阳性证据 → 该模型必须从列表消失
if _, err := ps.Call("channels", "__record_dead_probe",
map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil {
t.Fatalf("记录探针: %v", err)
}
after := modelIDsFor(t, ps, "zcode")
for _, id := range after {
if id == victim {
t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim)
}
}
if len(after) != len(before)-1 {
t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after))
}
// model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用)
m, err := ps.Call("channels", "model_clear_dead",
map[string]interface{}{"platform": "zcode", "model_id": victim})
if err != nil {
t.Fatalf("model_clear_dead: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["ok"] != true {
t.Fatalf("model_clear_dead 失败: %#v", m)
}
restored := modelIDsFor(t, ps, "zcode")
found := false
for _, id := range restored {
if id == victim {
found = true
}
}
if !found {
t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored)
}
}
// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。
// 判据用冷却状态本身,不依赖真实网络。
func TestRemoteCatalogGateCooldown(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 初始不冷却
g := func() (bool, bool) {
out, err := ps.Call("channels", "__catalog_gate_probe",
map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
began, _ := j["began"].(bool)
cooling, _ := j["cooling"].(bool)
return began, cooling
}
if _, cooling := g(); cooling {
t.Error("初始不该处于冷却")
}
// 模拟一次失败 → 进冷却
if _, err := ps.Call("channels", "__catalog_gate_fail_probe",
map[string]interface{}{"platform": "zcode"}); err != nil {
t.Fatalf("失败探针: %v", err)
}
began, cooling := g()
if !cooling {
t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)")
}
if began {
t.Error("冷却期内不该再发起拉取")
}
}
// modelIDsFor 读某平台当前对外的模型 id 列表。
func modelIDsFor(t *testing.T, ps interface {
Call(string, string, map[string]interface{}) (interface{}, error)
}, plat string) []string {
t.Helper()
m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat})
if err != nil {
t.Fatalf("model_list(%s): %v", plat, err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
var out []string
if arr, ok := j["models"].([]interface{}); ok {
for _, it := range arr {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
out = append(out, id)
}
}
}
}
return out
}

View File

@ -1,40 +0,0 @@
package core
import (
"testing"
)
// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。
//
// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts),
// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进),
// 不是实现细节。决策表本体在 deviceHarness.runDecisionTable(与变异测试共用)。
func TestChannelsDevicePollBehavior(t *testing.T) {
h := newDeviceHarness(t, mustChannelsSrc(t))
// begin 直查:user_code 透传、授权 URL、verifier 不回显
j := h.begin()
sess := j["session"].(map[string]interface{})
if sess["user_code"] != "ABCD-EFGH" {
t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"])
}
if j["url"] != "https://example/activate" {
t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"])
}
if _, has := j["device_verifier"]; has {
t.Fatal("verifier 绝不能回显")
}
// 决策表全过
if !h.runDecisionTable() {
t.Fatal("device-poll 决策表未全过(见 runDecisionTable 内的行号)")
}
}
// mustChannelsSrc 读 bundled channels 源码。
func mustChannelsSrc(t *testing.T) string {
t.Helper()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
return src
}

View File

@ -1,67 +0,0 @@
package core
import (
"testing"
)
// `isFree` 的判定链(照参考 cline-models.ts)。
//
// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**,
// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。
//
// 参考的判定链(权威在前):
//
// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合;
// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*)
// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底)
// ③ 声明的 models_free
// ④ 能力表的 cost(models.dev)
//
// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让
// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。
func TestIsFreeUsesAuthorityChain(t *testing.T) {
_, ps := poolCore(t)
// cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可)
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"})
if err != nil {
t.Fatalf("model_list: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
got := map[string]bool{}
for _, it := range asArrInterface(j["models"]) {
if mm, ok := it.(map[string]interface{}); ok {
if id, ok := mm["id"].(string); ok {
if f, ok := mm["isFree"].(bool); ok {
got[id] = f
}
}
}
}
// `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底)
if !got["cline-free/mimo-v2.6-flash"] {
t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got)
}
// stealth/space-bunny-alpha 来自远端 free 数组,也必须免费
if !got["stealth/space-bunny-alpha"] {
t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got)
}
// 命名约定必须生效(远端未响应时唯一的兜底依据)
probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "cline-free/some-new-model",
})
pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{})
if pj["is_free"] != true {
t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)")
}
// 反向:收费通道的同名模型**不得**被判免费
//(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型)
probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{
"platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash",
})
pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{})
if pj2["is_free"] == true {
t.Error("收费通道的模型不得因为前缀规则被判成免费")
}
}

View File

@ -1,63 +0,0 @@
package core
import (
"testing"
)
// 源注册必须**串行 + 节流**,而不是并发 2、无间隔。
//
// 照参考 model-gate.ts 的实测依据:这些平台的**并发配额按模型**分别计量,
// 与"还剩多少 token"无关(那边实测撞 429 时 token 还剩 299.4 万)。同账号下:
//
// GLM-5.3-Flash 605 次 200 0 次限流 ← 从未撞过
// GLM-5.3 74 次 200 21 次重试 6 次最终 429
//
// 两条措施缺一不可:
//
// ① **串行**(max_concurrent=1):同一时刻只允许一个上游请求在飞 ——
// 并发发起必然浪费一个配额。
// ② **最小间隔**(rpm):串行只保证"不重叠",不保证"有间隔" —— 连续放行的
// 两次调用可能只隔几十毫秒,仍会撞并发窗口。
//
// 此前插件注册源时一律 max_concurrent=2 且**从不设 rpm** ⇒ 既会重叠又无间隔。
func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
c, ps := poolCore(t)
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "gated",
"credential": `{"access_token":"TOK"}`,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
j, _ := m.(map[string]interface{})["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 失败: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
t.Fatal("账号未出源")
}
// ① 串行
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])
}
// ② 节流(rpm>0 才有间隔)
if rpm, _ := toFloat(sm["rpm"]); rpm <= 0 {
t.Errorf("源必须设 rpm>0(串行不保证间隔),实得 %v", sm["rpm"])
}
}
func toFloat(v interface{}) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case int:
return float64(x), true
case int64:
return float64(x), true
}
return 0, false
}

View File

@ -1,110 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// 有道每日活动领取是**三步流程**,参考注释点名:无可用活动是**正常业务状态**,
// 不能显示成"领取失败"(否则用户会去查网络/账号,方向全错)。
// 本判据钉住决策表的四类结果:inactive / already-claimed / success / failed。
func TestLobsterActivityClaimDecisionTable(t *testing.T) {
type c struct {
name string
slot string
ctx string
wantKind string
wantCalled bool // 是否应发出 claim 请求
}
cases := []c{
{"槽位不可用→inactive", `{"code":0,"data":{"slotState":"unavailable","activity":{"activityCode":"A1"}}}`, "", "inactive", false},
{"无 activityCode→inactive", `{"code":0,"data":{"slotState":"available","activity":{}}}`, "", "inactive", false},
{"今天已领→already-claimed", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":true},"actions":["check_in"]}}`, "already-claimed", false},
{"无 check_in 动作→inactive", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["share"]}}`, "inactive", false},
{"可领→success", `{"code":0,"data":{"slotState":"available","activity":{"activityCode":"A1","configRevision":7}}}`,
`{"code":0,"data":{"state":{"claimedToday":false},"actions":["check_in"]}}`, "success", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimHit := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/client-activities/slot":
fmt.Fprint(w, tc.slot)
case r.URL.Path == "/api/client-activities/A1/context":
if tc.ctx == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.ctx)
case r.URL.Path == "/api/client-activities/A1/actions/check_in":
claimHit++
b := make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
claimBody = string(b)
fmt.Fprint(w, `{"code":0,"data":{"result":{"creditsGranted":30}}}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 把账号基址指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"lobster_activity_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 存入凭据(claim 需要结构化 credential 对象)
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "lobsterai", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"lobsterai"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome 判别联合,实得 %v", j)
}
if k, _ := oc["kind"].(string); k != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %v(%v)", tc.wantKind, oc["kind"], oc["message"])
}
if (claimHit > 0) != tc.wantCalled {
t.Errorf("claim 请求次数 = %d,期望发出=%v", claimHit, tc.wantCalled)
}
if tc.wantCalled && claimBody == "" {
t.Error("claim 请求体不应为空(要带 configRevision 与幂等键)")
}
})
}
}

View File

@ -1,75 +0,0 @@
package core
import (
"encoding/json"
"llmsproxy/internal/lua"
"net/http"
"net/http/httptest"
"testing"
)
func TestLobsterExchangePreservesIdentity(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var b map[string]string
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
t.Error(err)
}
if b["authCode"] != "quote\"&code" || len(b["uuid"]) != 36 || b["firstKeyfrom"] == "" || b["firstKeyfrom"] == "cli" {
t.Errorf("invalid exchange fields: %#v", b)
}
if r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "LobsterAI/0.1.0" {
t.Error("wrong headers")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0,"data":{"accessToken":"at-test","refreshToken":"rt-test","expiresIn":3600,"user":{"id":"uid1","userId":"account1","nickname":"test"}}}`))
}))
defer ts.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err = ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// The fixture changes only the transport destination, not the upstream contract.
if _, err = ps.Call("channels", "set_endpoints", map[string]interface{}{"platform": "lobsterai", "callback": map[string]interface{}{"token_url": ts.URL}}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin"})
if err != nil {
t.Fatal(err)
}
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
req := &lua.HandlerRequest{Method: "GET", Query: map[string]string{"state": nonce, "authCode": "quote\"&code"}}
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
if err != nil {
t.Fatal(err)
}
if out.Status != 200 {
t.Fatalf("callback: %#v", out)
}
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
if err != nil || !ok {
t.Fatal("missing credential", err)
}
var cred map[string]interface{}
if err = json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "latest_keyfrom", "expires_at", "uid", "user_id"} {
if cred[k] == nil || cred[k] == "" {
t.Errorf("missing %s", k)
}
}
out, err = ps.InvokeHandler("channels", "oauth_callback", req)
if err != nil {
t.Fatal(err)
}
if out.Status != 409 || calls != 1 {
t.Fatalf("replay accepted: status=%d calls=%d", out.Status, calls)
}
}

View File

@ -1,165 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"llmsproxy/internal/lua"
)
// 有道登录闭环的判据:portal 会把授权重定向回 **本机 loopback 接收器**
// (127.0.0.1:{port}/auth/callback 精确路径),而不是用户机器的 localhost、
// 也不是本插件的 /api/plugins/http 挂载点。远程用户在服务端浏览器里完成后,
// 重定向落本机端口,接收器写文件,poll_login 读它完成交换。
//
// 参考 lobsterai-oauth.ts 明确:redirect_uri 必须该形态(登录页会校验),
// state 不匹配必须拒绝(400),交换体含 authCode/firstKeyfrom/latestKeyfrom/
// uuid/version 五字段,凭据必须带 uuid+first_keyfrom(否则续期失败)。
func TestLobsterLoopbackClosedLoop(t *testing.T) {
var exch int
var gotVersion string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
exch++
var b map[string]string
_ = json.NewDecoder(r.Body).Decode(&b)
if b["authCode"] != "AC-1" {
t.Errorf("exchange authCode = %q", b["authCode"])
}
gotVersion = b["version"]
if b["firstKeyfrom"] == "" || b["uuid"] == "" {
t.Errorf("exchange 缺字段: %#v", b)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"code":0,"data":{"accessToken":"AT-1","refreshToken":"RT-1","expiresIn":3600,"user":{"id":"UID1"}}}`)
}))
defer ts.Close()
// 版本号是 exchange 的必填字段,**不能写死**:2026-10-13 实测上游已是
// 2026.9.23,而参考的兜底常量还停在 2026.9.4。此处假上游返回真形态
// (非统一信封,值在 data.value.version)。
ver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"data":{"value":{"version":"2026.9.23","date":"2026-9-23"}}}`)
}))
defer ver.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "status"}, false)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "lobsterai",
"callback": map[string]interface{}{"token_url": ts.URL},
"client_version": map[string]interface{}{"url": ver.URL},
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"lobsterai"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
sess := j["session"].(map[string]interface{})
nonce := sess["nonce"].(string)
sid := sess["id"].(string)
redirectURI, _ := j["redirect_uri"].(string)
if !strings.HasPrefix(redirectURI, "http://127.0.0.1:") || !strings.HasSuffix(redirectURI, "/auth/callback") {
t.Fatalf("redirect_uri 必须是 127.0.0.1:{port}/auth/callback,实得 %q", redirectURI)
}
url, _ := j["url"].(string)
if !strings.Contains(url, "/portal#/login?") {
t.Fatalf("授权页应是 /portal#/login?…,实得 %q", url)
}
if !strings.Contains(url, "source=electron") {
t.Fatalf("授权页缺 source=electron: %q", url)
}
t.Run("行1 state 不匹配被拒", func(t *testing.T) {
resp, err := http.Get(redirectURI + "?code=AC-1&state=WRONG")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Fatalf("state 不匹配应 400,实得 %d", resp.StatusCode)
}
if exch != 0 {
t.Fatal("state 不匹配不应触发交换")
}
})
t.Run("行2 正确回调完成登录并落完整凭据", func(t *testing.T) {
resp, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("合法回调应 200,实得 %d", resp.StatusCode)
}
// 接收器写文件后 poll_login 读它完成交换(可能需要一拍)
var st map[string]interface{}
for i := 0; i < 20; i++ {
o, err := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
st = o.JSON.(map[string]interface{})
if st["state"] != "pending" {
break
}
time.Sleep(100 * time.Millisecond)
}
if st["state"] != "completed" {
t.Fatalf("应完成登录,实得 %v", st)
}
raw, ok, err := ps.GetSecret("channels", "channels:lobsterai")
if err != nil || !ok {
t.Fatalf("凭据未落库: %v", err)
}
var cred map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cred); err != nil {
t.Fatal(err)
}
for _, k := range []string{"access_token", "refresh_token", "uuid", "first_keyfrom", "expires_at", "uid"} {
if cred[k] == nil || cred[k] == "" {
t.Errorf("凭据缺 %s(有道的 uuid/first_keyfrom 丢了就续期失败): %s", k, raw)
}
}
if cred["access_token"] != "AT-1" {
t.Fatalf("access_token = %v", cred["access_token"])
}
// 版本号必须来自远端(2026.9.23),不能是写死的兜底 2026.9.4
if gotVersion != "2026.9.23" {
t.Fatalf("exchange version 应为远端 2026.9.23(写死 2026.9.4 是 bug),实得 %q", gotVersion)
}
})
t.Run("行3 接收器用完即收", func(t *testing.T) {
// 完成后监听应已关闭(连接被拒)
_, err := http.Get(redirectURI + "?code=AC-1&state=" + nonce)
if err == nil {
t.Fatal("接收器应在完成后关闭")
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "connect") {
t.Logf("关闭判定: %v", err)
}
})
_ = fmt.Sprint
}

View File

@ -1,177 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// loomyHarness:Loomy 微信扫码长轮询的测试台。
//
// 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"):
//
// 408 = 等待扫码;404 = 已扫码待确认(继续轮询);
// 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效
type loomyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
confirmAt int32
gotAuth string
}
func newLoomyHarness(t *testing.T) *loomyHarness {
t.Helper()
h := &loomyHarness{t: t, confirmAt: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"):
w.Header().Set("Content-Type", "text/html")
// 夹具必须用**真实微信页面的形态**:uuid 内嵌在
// <img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>。
// 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。
fmt.Fprint(w, `<html><img class="js_qrcode_img" src="/connect/qrcode/UUID-TEST-1"/></html>`)
case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"):
n := atomic.AddInt32(&h.polls, 1)
w.Header().Set("Content-Type", "text/plain")
if n == 1 {
fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码
return
}
if n < atomic.LoadInt32(&h.confirmAt) {
fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认
return
}
fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认
case r.URL.Path == "/login/thirdAccount/bind/auth":
h.gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "loomy",
"wechat": map[string]interface{}{
"auth_url": h.ts.URL + "/connect/qrconnect?state={state}",
"qr_url": h.ts.URL + "/connect/qrcode/{uuid}",
"poll_url": h.ts.URL + "/connect/l/qrconnect",
"account_base": h.ts.URL,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *loomyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *loomyHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。
func TestLoomyWechatPollBehavior(t *testing.T) {
h := newLoomyHarness(t)
t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "UUID-TEST-1") {
t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr)
}
})
t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 99)
_, sid := h.begin()
// 第一拍 408
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("408 应 pending, 实得 %v", st["state"])
}
// 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里)
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"])
}
})
t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.confirmAt, 3)
_, sid := h.begin()
h.pollOnce(sid) // 408
h.pollOnce(sid) // 404
st := h.pollOnce(sid) // 405
if st["state"] != "completed" {
t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"])
}
// 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer
if !strings.HasPrefix(h.gotAuth, "account ") {
t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth)
}
if !strings.Contains(h.gotAuth, ":") {
t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:loomy")
if !ok || !strings.Contains(v, "RCODE-1") {
t.Fatalf("凭据应含 rcode, 实得 %q", v)
}
})
t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) {
for _, tc := range []struct{ errcode, want string }{
{"403", "取消"}, {"402", "失效"},
} {
hh := newLoomyHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") {
fmt.Fprint(w, `<img src="/connect/qrcode/U">`)
return
}
fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "failed" {
t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"])
}
}
})
}

View File

@ -1,201 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// 远端模型目录的判据:**远端为主 + 兜底补位**(照参考 reconcileWithFallback)。
//
// 参考明确:"正确做法是**合并**,而不是「远端失败就用兜底」的简单回退"
// (cline-models.ts)。故这里逐条锁定:
// - 远端有的以远端为准(顺序也照远端)
// - 远端没有的用兜底表补位
// - 拉取失败**不清空**既有缓存(宁可留旧目录也别让平台变空)
// - 平台声明的真实目录 key 必须在上游源里(占位名会让选模型调不通)
type modelsHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
mu sync.Mutex
body string
code int
hits int
}
func newModelsHarness(t *testing.T, platform, body string) *modelsHarness {
t.Helper()
h := &modelsHarness{t: t, body: body, code: 200}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.hits++
code, body := h.code, h.body
h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, body)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "remove_source", "list_sources", "status"}, false)
// 说明:测试上游是 127.0.0.1(loopback),出站白名单默认允许,
// 不需要额外授权 —— 也就不需要碰内核的授权 API。
// 把该平台的远端端点指到假上游
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": platform,
"models_remote": map[string]interface{}{
"endpoint0": h.ts.URL,
},
}); err != nil {
t.Logf("set_endpoints(models_remote) 未支持: %v", err)
}
return h
}
// modelIDs 从 model_list 的 Call 返回值({json:{models:[...]}})取 id 列表。
func modelIDs(t *testing.T, got interface{}) []string {
t.Helper()
j := got.(map[string]interface{})["json"].(map[string]interface{})
ms, _ := j["models"].([]interface{})
ids := make([]string, 0, len(ms))
for _, e := range ms {
ids = append(ids, e.(map[string]interface{})["id"].(string))
}
return ids
}
func TestRemoteModelCatalogMergesWithFallback(t *testing.T) {
// zcode:远端 builtinModels 是**对象**(键为序号),且远端/兜底有重叠
body := `{"data":{"builtinModels":{"0":{"modelId":"GLM-5.3"},"1":{"modelId":"GLM-5.3-Flash"},"2":{"modelId":"NEW-MODEL-X"}}}}`
h := newModelsHarness(t, "zcode", body)
t.Run("行1 远端拉到就用远端并保留兜底独有的", func(t *testing.T) {
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]interface{})["json"].(map[string]interface{})
res := m["results"].([]interface{})[0].(map[string]interface{})
if res["ok"] != true {
t.Fatalf("刷新应成功, 实得 %v", res)
}
// NEW-MODEL-X 只由远端下发,必须出现(证明用的不是硬编码表)
ml, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, ml)
joined := strings.Join(ids, ",")
if !strings.Contains(joined, "NEW-MODEL-X") {
t.Fatalf("只由远端下发的模型必须出现(否则是硬编码表): %v", ids)
}
if !strings.Contains(joined, "GLM-5.3") {
t.Fatalf("兜底/远端共有项应保留: %v", ids)
}
// 远端与兜底重叠项不得重复
seen := map[string]int{}
for _, id := range ids {
seen[id]++
if seen[id] > 1 {
t.Fatalf("模型 %s 重复出现(合并应去重): %v", id, ids)
}
}
})
t.Run("行2 拉取失败不清空既有缓存", func(t *testing.T) {
// 先成功一次建立缓存
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"}); err != nil {
t.Fatal(err)
}
// 再让上游 500
h.mu.Lock()
h.code = 500
h.body = `{}`
h.mu.Unlock()
got, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "zcode"})
if err != nil {
t.Fatal(err)
}
res := got.(map[string]interface{})["json"].(map[string]interface{})["results"].([]interface{})[0].(map[string]interface{})
if res["ok"] == true {
t.Fatalf("上游 500 时刷新应报失败, 实得 %v", res)
}
ml, _ := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "zcode"})
ids := modelIDs(t, ml)
if !strings.Contains(strings.Join(ids, ","), "NEW-MODEL-X") {
t.Fatalf("拉取失败必须保留既有目录(不能让平台变空): %v", ids)
}
})
}
// TestPlatformModelsAreRealCatalogKeys 锁定 12 平台的模型名**不是编的占位名**。
// 此前 qoder/zcode/loomy/raccoon 用的是 "*-default"(我编的),上游不认 ——
// 后果是这些平台登录成功后选任何模型都调不通,而测试全绿(它测的就是我编的名字)。
func TestPlatformModelsAreRealCatalogKeys(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
got, err := ps.Call("channels", "provider_status", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
plats := got.(map[string]interface{})["json"].(map[string]interface{})["statuses"].([]interface{})
if len(plats) != 12 {
t.Fatalf("应有 12 个真实平台, 实得 %d", len(plats))
}
realQoder := []string{"qfmodel", "qmodel_38max", "kmodel_latest", "dmodel"}
sawQoder := 0
for _, e := range plats {
p := e.(map[string]interface{})
id := p["id"].(string)
ml, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": id})
if err != nil {
t.Fatalf("平台 %s model_list: %v", id, err)
}
ids := modelIDs(t, ml)
if len(ids) == 0 {
t.Fatalf("平台 %s 无模型", id)
}
for _, mid := range ids {
// 占位名特征:以 -default 结尾
if strings.HasSuffix(mid, "-default") {
t.Fatalf("平台 %s 的模型 %q 是编的占位名(上游不认,选了调不通)", id, mid)
}
}
if id == "qoder" {
j := map[string]bool{}
for _, mid := range ids {
j[mid] = true
}
for _, want := range realQoder {
if j[want] {
sawQoder++
}
}
}
}
if sawQoder == 0 {
t.Fatal("qoder 应含参考的真实目录 key(如 qfmodel),而非编造的占位名")
}
}

View File

@ -1,405 +0,0 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// channelsMoreTest:新引入的三条真实流程 + 两个边界流的行为对齐测试。
// 平台定义、决策表、端点逐字照抄参考(见 channels.lua 注释),测试照参考行为搬。
// ---- Cline(WorkOS 设备码 + register 两步) ----
// clineHarness 假上游:device code / authenticate / register 三端点。
type clineHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
tok atomic.Value // 当前 authenticate 响应脚本 []scriptStep
step int32
}
func newClineHarness(t *testing.T) *clineHarness {
t.Helper()
h := &clineHarness{t: t}
h.tok.Store([]scriptStep{})
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/user_management/authorize/device":
fmt.Fprint(w, `{"device_code":"CD1","user_code":"WXYZ-1234","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/user_management/authenticate":
i := int(atomic.AddInt32(&h.step, 1)) - 1
s := h.tok.Load().([]scriptStep)
if i >= len(s) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if s[i].http != 0 && s[i].http != 200 {
w.WriteHeader(s[i].http)
}
fmt.Fprint(w, s[i].body)
case "/api/v1/auth/register":
// 校验请求体是驼峰 accessToken/refreshToken(参考 registerClineTokens)
b, _ := io.ReadAll(r.Body)
for _, want := range []string{`"accessToken":"WO-AT-1"`, `"refreshToken":"WO-RT-1"`} {
if !strings.Contains(string(b), want) {
t.Errorf("register 请求体缺 %s: %s", want, b)
}
}
fmt.Fprint(w, `{"success":true,"data":{"accessToken":"CLINE-AT-1","refreshToken":"CLINE-RT-1","userInfo":{"clineUserId":"u1","email":"dev@example.com"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, _ := readBundledChannels(t)
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cline",
"device": map[string]interface{}{"code_url": h.ts.URL + "/user_management/authorize/device", "token_url": h.ts.URL + "/user_management/authenticate"},
"register": map[string]interface{}{"url": h.ts.URL + "/api/v1/auth/register"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *clineHarness) begin() map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"cline"}`,
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
func (h *clineHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// runWorkosTable 是 WorkOS(Cline)分支的行为契约,供变异测试杀 workos 变异体。
// 决策表 5 行,跟 TestClineWorkOSBehavior 同源(这里只返回布尔,不报细节)。
func (h *clineHarness) runWorkosTable() bool {
st := func(steps ...string) map[string]interface{} {
h.tok.Store(scriptSteps(steps...))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// 行1: 非200+error=authorization_pending → 等
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
return false
}
// 行2: 非200+error=slow_down → 等 + interval+1(基线 1)
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv != 2 {
return false
}
// 行3: error=access_denied → failed
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
return false
}
// 行4: 成功(access+refresh)→ register 换 CLINE token → completed
if j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`); j["state"] != "completed" || j["account"] != "dev@example.com" {
return false
}
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
if v != "CLINE-AT-1" {
return false
}
// 行5: 非200无error字段 → failed(不是死等)
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
return false
}
return true
}
// TestClineWorkOSBehavior:WorkOS 判据(error 字段)+ register 两步 + slow_down +1s。
func TestClineWorkOSBehavior(t *testing.T) {
h := newClineHarness(t)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
st := func(steps ...string) map[string]interface{} {
h.tok.Store(scriptSteps(steps...))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
t.Run("行1 error=authorization_pending_非200也继续等", func(t *testing.T) {
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
t.Fatalf("应 pending: %v", j)
}
})
t.Run("行2 error=slow_down_退避1秒", func(t *testing.T) {
h.tok.Store(scriptSteps(`400 {"error":"slow_down"}`))
atomic.StoreInt32(&h.step, 0)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
j2 := h.poll(sid)
if j2["state"] != "pending" {
t.Fatalf("应 pending: %v", j2)
}
if iv, _ := j2["interval"].(float64); iv != 2 { // 基线 1 + slow_down_step 1
t.Fatalf("WorkOS slow_down 应 interval+1s 得 2, 实得 %v", iv)
}
})
t.Run("行3 error=access_denied_失败带拒绝", func(t *testing.T) {
if j := st(`400 {"error":"access_denied","error_description":"user declined"}`); j["state"] != "failed" {
t.Fatalf("应 failed: %v", j)
}
})
t.Run("行4 成功_access+refresh_再打register_凭据是CLINE-AT-1", func(t *testing.T) {
j := st(`{"access_token":"WO-AT-1","refresh_token":"WO-RT-1"}`)
if j["state"] != "completed" || j["account"] != "dev@example.com" {
t.Fatalf("应 completed+dev@example.com: %v", j)
}
v, _, _ := h.ps.GetSecret("channels", "channels:cline")
if v != "CLINE-AT-1" {
t.Fatalf("凭据应是 register 的 CLINE-AT-1, 实得 %q", v)
}
})
t.Run("行5 非200无error字段_失败", func(t *testing.T) {
if j := st(`500 {"foo":"bar"}`); j["state"] != "failed" {
t.Fatalf("应 failed: %v", j)
}
})
}
// ---- Buddy(CodeBuddy state 轮询) ----
type buddyHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
tokStep int32
tokOk int32
acctOk int32
}
func newBuddyHarness(t *testing.T) *buddyHarness {
t.Helper()
h := &buddyHarness{t: t, tokOk: 1, acctOk: 1}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/v2/plugin/auth/state":
fmt.Fprint(w, `{"data":{"state":"ST-1","authUrl":"https://www.codebuddy.cn/login/?platform=ide&state=ST-1"}}`)
case r.Method == "GET" && r.URL.Path == "/v2/plugin/auth/token":
i := atomic.AddInt32(&h.tokStep, 1)
if atomic.LoadInt32(&h.tokOk) == 0 {
w.WriteHeader(200)
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
return
}
if i == 1 {
fmt.Fprint(w, `{"code":11217,"message":"token not ready"}`)
} else {
fmt.Fprint(w, `{"data":{"accessToken":"BD-AT-1","refreshToken":"BD-RT-1"}}`)
}
case r.Method == "GET" && r.URL.Path == "/v2/plugin/login/account":
if atomic.LoadInt32(&h.acctOk) == 0 {
w.WriteHeader(500)
fmt.Fprint(w, `{"code":1}`)
return
}
fmt.Fprint(w, `{"data":{"nickName":"buddy-dev"}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, _ := readBundledChannels(t)
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"state": map[string]interface{}{"state_url": h.ts.URL + "/v2/plugin/auth/state", "token_url": h.ts.URL + "/v2/plugin/auth/token", "account_url": h.ts.URL + "/v2/plugin/login/account"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *buddyHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string), aid
}
func (h *buddyHarness) accountList() []interface{} {
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`,
})
if err != nil {
h.t.Fatal(err)
}
m := out.JSON.(map[string]interface{})
accts, _ := m["accounts"].([]interface{})
return accts
}
func (h *buddyHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// TestBuddyStatePollBehavior:state-poll 两拍(先 11217 后 token)+ 账号查询 + account 失败不阻塞。
func TestBuddyStatePollBehavior(t *testing.T) {
h := newBuddyHarness(t)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
t.Run("行1 state申请给出authUrl", func(t *testing.T) {
j, _ := h.begin()
if j["url"] == "" {
t.Fatalf("应有 authUrl: %v", j)
}
})
t.Run("行2 首拍11217仍pending_次拍token_completed", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
_, sid := h.begin()
if j := h.poll(sid); j["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", j)
}
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
}
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
t.Fatalf("凭据应含 access_token, 实得 %q", v)
}
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
}
if !strings.Contains(v, "buddy-dev") {
t.Fatalf("凭据应回填昵称, 实得 %q", v)
}
})
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
atomic.StoreInt32(&h.acctOk, 0)
defer atomic.StoreInt32(&h.acctOk, 1)
_, sid := h.begin()
h.poll(sid)
j := h.poll(sid)
if j["state"] != "completed" {
t.Fatalf("account 失败不应阻塞: %v", j)
}
})
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
// has_credential=false、credits 报「无凭据」——即用户报障的
// 「登录了但无法获取账号信息」。
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
atomic.StoreInt32(&h.tokStep, 0)
_, sid, aid := h.beginPool()
h.poll(sid) // 首拍 11217
j := h.poll(sid)
if j["state"] != "completed" {
t.Fatalf("池路径登录应完成: %v", j)
}
// ① 凭据必须落在池账号的 ref 上
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
if !ok || v == "" {
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
}
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
t.Fatalf("池凭据应是完整 JSON: %q", v)
}
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
found := false
for _, raw := range h.accountList() {
a, _ := raw.(map[string]interface{})
if a["id"] == aid {
found = true
if a["has_credential"] != true {
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
}
if a["refreshable"] != true {
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
}
}
}
if !found {
t.Fatalf("池账号 %s 未出现在 account_list", aid)
}
})
}
// ---- 通用 ----
func scriptSteps(steps ...string) []scriptStep {
var out []scriptStep
for _, b := range steps {
code := 200
switch {
case strings.HasPrefix(b, "400 "):
code, b = 400, b[4:]
case strings.HasPrefix(b, "403 "):
code, b = 403, b[4:]
case strings.HasPrefix(b, "500 "):
code, b = 500, b[4:]
}
out = append(out, scriptStep{http: code, body: b})
}
return out
}

View File

@ -1,438 +0,0 @@
package core
import (
"encoding/json"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsPluginEndToEnd 是「同类插件」的完整验收:
// 内置的 channels 插件在**真实内核**上跑通「登录 → 存凭据 → 接入为上游源」。
//
// 这条链路横跨本次所有新增能力,所以它是最有信息量的一个测试:
//
// __llmsproxy_kernel add_source 把平台注册为上游源
// 内核权限 默认拒绝,需运维授权
// 额外出站主机授权 登录域不在源列表里,默认被出站白名单拒绝(plugin.hosts)
// 登录会话 插件自持(plugin.state.sessions),内核无登录概念
func TestChannelsPluginEndToEnd(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatalf("读取内置 channels 插件: %v", err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
// 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
// (否则会出现在生产 UI 的平台列表里)。
injectTestPlatforms(t, ps, examplePlatformFixture)
// 1. 平台清单可用(未授权任何东西也应能列出)
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("list_platforms: %v", err)
}
body, _ := out.JSON.(map[string]interface{})
plats, _ := body["platforms"].([]interface{})
if len(plats) < 3 {
t.Fatalf("应至少 3 个平台, 实得 %d", len(plats))
}
// 2. 发起登录 → 拿到会话与 URL
out, err = ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if m["error"] != nil {
t.Fatalf("发起登录失败: %v", m["error"])
}
if m["url"] == nil || m["url"] == "" {
t.Fatal("应返回用户要打开的登录 URL")
}
sess := m["session"].(map[string]interface{})
id, _ := sess["id"].(string)
if id == "" {
t.Fatal("应返回会话 id")
}
// 3. 轮询到 pending
out, _ = ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id}, Role: "admin",
})
if st := out.JSON.(map[string]interface{})["state"]; st != "pending" {
t.Fatalf("应为 pending, 实得 %v", st)
}
// 4. 未授权内核动作时,接入源必须失败且**如实报告**(不是假装成功)
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
})
if err != nil {
t.Fatalf("complete_login: %v", err)
}
done := out.JSON.(map[string]interface{})
if done["source_error"] == nil {
t.Fatal("未授权 add_source 时应报告 source_error")
}
t.Logf("未授权时的如实报告: %v", done["source_error"])
// 凭据必须已在保险库(登录本身成功了)
tok, ok, _ := ps.GetSecret("channels", "channels:example")
if !ok || tok != "tok-1" {
t.Fatalf("凭据应已存保险库: ok=%v tok=%q", ok, tok)
}
// 且不得出现在 state 里
if stateHasSecret(ps, "channels", "tok-1") {
t.Fatal("凭据泄漏进了 plugin.state")
}
// 5. 运维授权后,重新接入应当真的把源注册进内核
if err := ps.SetPluginKernelPermission("channels",
[]string{"add_source", "list_sources", "status"}, false); err != nil {
t.Fatal(err)
}
out, err = ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"example","credential":"tok-1","account":"alice"}`, Role: "admin",
})
if err != nil {
t.Fatalf("complete_login(授权后): %v", err)
}
done = out.JSON.(map[string]interface{})
if done["source_error"] != nil {
t.Fatalf("授权后仍失败: %v", done["source_error"])
}
if done["source"] != "channels-example" {
t.Fatalf("源名不对: %v", done["source"])
}
// 内核侧必须真的有这个源
found := false
for _, s := range c.Sources() {
if s.Name == "channels-example" {
found = true
if s.BaseURL != "https://api.example.com/v1" {
t.Errorf("base_url 未落库: %q", s.BaseURL)
}
}
}
if !found {
t.Fatal("插件接入的源没有出现在内核源列表里")
}
// 平台声明的模型应可路由
models := c.Registry().ModelList()
hasModel := false
for _, id := range models {
if id == "example-chat" {
hasModel = true
}
}
if !hasModel {
t.Errorf("平台模型未进入可路由列表: %v", models)
}
t.Logf("已接入源 channels-example,模型可见: %v", models)
}
// TestChannelsPluginDeclaresItsNeeds 断言插件如实声明了它需要什么。
//
// 声明是运维审批的依据:插件不声明,设置页就无法显示"申请中",
// 运维只能等某个功能静默失效后才发现。
func TestChannelsPluginDeclaresItsNeeds(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 内核动作声明
views := ps.KernelPermissions()
var declared []string
for _, v := range views {
if v.Plugin == "channels" {
declared = v.Declared
}
}
if len(declared) == 0 {
t.Fatal("channels 应声明它需要的内核动作")
}
for _, want := range []string{"add_source", "status"} {
found := false
for _, d := range declared {
if d == want {
found = true
}
}
if !found {
t.Errorf("应声明 %q, 实得 %v", want, declared)
}
}
// 额外出站主机声明(非源 host,需单独批准)
hosts := ps.PluginExtraHosts("channels")
if len(hosts) == 0 {
t.Fatal("channels 应声明额外出站主机")
}
t.Logf("声明内核动作=%v 额外主机=%v", declared, hosts)
}
// TestChannelsPluginHandlersRequireStatus 是一个契约回归:插件的每个 handler
// 都必须返回 {status,json/body},否则内核会**静默丢弃**它(实测踩过:
// `return {url=…}` 得到 200 空响应,没有任何错误信息)。
func TestChannelsPluginHandlersRequireStatus(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// 每个 handler 至少要在正常输入下给出非空响应
cases := []struct {
fn string
req *lua.HandlerRequest
what string
}{
{"list_platforms", &lua.HandlerRequest{Method: "GET"}, "平台列表"},
{"status_page", &lua.HandlerRequest{Method: "GET"}, "状态页"},
{"poll_login", &lua.HandlerRequest{Method: "GET", Query: map[string]string{"id": "nope"}}, "轮询(未知会话)"},
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "发起登录"},
{"start_login", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"nope"}`}, "发起登录(未知平台)"},
{"complete_login", &lua.HandlerRequest{Method: "POST", Body: `{}`}, "完成登录(缺字段)"},
{"claim_daily", &lua.HandlerRequest{Method: "POST", Body: `{"platform":"example"}`}, "领额度(未登录)"},
}
for _, tc := range cases {
out, err := ps.InvokeHandler("channels", tc.fn, tc.req)
if err != nil {
t.Errorf("%s (%s) 返回错误: %v", tc.fn, tc.what, err)
continue
}
if out.JSON == nil && out.Body == "" {
t.Errorf("%s (%s) 返回空响应 —— 内核会静默丢弃,调用方看不到任何原因",
tc.fn, tc.what)
}
}
}
// jsonMarshalForTest 序列化一个接口值,用于检查凭据是否出现在 state 里。
func jsonMarshalForTest(v interface{}) ([]byte, error) { return json.Marshal(v) }
// stateHasSecret 检查凭据是否泄漏进 plugin.state。
func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
st := ps.State(plugin)
if st == nil {
return false
}
b, err := jsonMarshalForTest(st)
if err != nil {
return false
}
return contains(string(b), secret)
}
// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
// 生产插件的 PLATFORMS 只含真实厂商。
const examplePlatformFixture = `[
{
"id": "example",
"name": "Example 平台(测试夹具)",
"flow": "link-poll",
"login_url": "https://auth.example.com/device",
"base_url": "https://api.example.com/v1",
"adapter": "openai",
"models": ["example-chat", "example-reason"],
"checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"}
}
]`
// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
t.Helper()
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
})
if err != nil {
t.Fatalf("inject_platforms: %v", err)
}
m, _ := out.JSON.(map[string]interface{})
if m["ok"] != true {
t.Fatalf("注入测试平台失败: %v", m)
}
}
// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
// 2. 重复 id 拒绝
// 3. 空数组 = 清空注入(夹具不跨测试残留)
//
// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
// (真实踩过 —— 16 个平台里有 4 个是夹具)。
func TestInjectPlatformsRejectsBadInput(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
call := func(body string) map[string]interface{} {
out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
Method: "POST", Body: body, Role: "admin",
})
if err != nil {
t.Fatalf("inject_platforms(%s): %v", body, err)
}
return out.JSON.(map[string]interface{})
}
platforms := func() []interface{} {
out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
return out.JSON.(map[string]interface{})["platforms"].([]interface{})
}
base := len(platforms())
if base == 0 {
t.Fatal("生产目录应有真实厂商平台")
}
// 注入一个合法夹具 → 列表 +1
if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
t.Fatalf("合法注入应成功: %v", m)
}
if got := len(platforms()); got != base+1 {
t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
}
// 1. 覆盖内建平台 → 拒绝
if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
t.Fatal("覆盖内建平台必须被拒绝")
}
// 2. 重复 id → 拒绝
if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
t.Fatal("重复 id 必须被拒绝")
}
// 无 id → 拒绝
if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
t.Fatal("缺 id 必须被拒绝")
}
// 3. 空数组 = 清空注入,回到基线
if m := call(`{"platforms":[]}`); m["ok"] != true {
t.Fatalf("清空应成功: %v", m)
}
if got := len(platforms()); got != base {
t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
}
}
// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
//
// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
func TestChannelsBrowserStackDeps(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("deps_status: %v", err)
}
m, ok := out.JSON.(map[string]interface{})
if !ok {
t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
}
if _, has := m["ok"]; !has {
t.Fatal("deps_status 必须报告 ok")
}
if _, has := m["missing"]; !has {
t.Fatal("deps_status 必须报告 missing 清单")
}
if m["ok"] == false {
hint, _ := m["install_hint"].(string)
if hint == "" {
t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
}
// 每个缺失项都要说清是谁、装什么包、干什么用
for _, raw := range asArr(m["missing"]) {
e := raw.(map[string]interface{})
for _, k := range []string{"bin", "pkg", "why"} {
if s, _ := e[k].(string); s == "" {
t.Fatalf("缺少字段 %s: %v", k, e)
}
}
}
t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
}
}
// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
// 必须**如实报错**,而不是假装成功或 panic。
func TestChannelsBrowserEndpointsRequireSession(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 无会话时 collect 必须报错
out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatalf("collect_credentials: %v", err)
}
if out.JSON.(map[string]interface{})["error"] == nil {
t.Fatal("无浏览器会话时 collect_credentials 必须报错")
}
// status 如实说 running=false
out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("browser_status: %v", err)
}
if out.JSON.(map[string]interface{})["running"] != false {
t.Fatal("无会话时 running 应为 false")
}
// stop 幂等:无会话时也返回 ok
out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
if err != nil {
t.Fatalf("stop_browser: %v", err)
}
if out.JSON.(map[string]interface{})["ok"] != true {
t.Fatal("stop_browser 应幂等成功")
}
}

View File

@ -1,439 +0,0 @@
package core
import (
"strings"
"testing"
"llmsproxy/internal/lua"
"fmt"
"net/http"
"net/http/httptest"
)
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。
func poolCore(t *testing.T) (*Core, *lua.Plugins) {
t.Helper()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false)
return c, ps
}
// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。
func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} {
t.Helper()
out, err := c.Invoke(nil, "list_sources", map[string]interface{}{})
if err != nil {
t.Fatalf("list_sources: %v", err)
}
if ms, ok := out.([]map[string]interface{}); ok {
return ms
}
raw, _ := out.([]interface{})
var srcs []map[string]interface{}
for _, s := range raw {
if m, ok := s.(map[string]interface{}); ok {
srcs = append(srcs, m)
}
}
return srcs
}
func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} {
t.Helper()
out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body})
if err != nil {
t.Fatalf("invoke %s: %v", fn, err)
}
return out.JSON.(map[string]interface{})
}
// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组
// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。
func asArr(v interface{}) []interface{} {
switch a := v.(type) {
case []interface{}:
return a
case []string:
out := make([]interface{}, len(a))
for i, s := range a {
out[i] = s
}
return out
}
return nil
}
func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) {
for _, sm := range srcs {
if name, _ := sm["name"].(string); strings.Contains(name, needle) {
return sm, true
}
}
return nil, false
}
// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。
func TestChannelsAccountPoolBehavior(t *testing.T) {
c, ps := poolCore(t)
t.Run("账号增删改", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`)
if m["ok"] != true {
t.Fatalf("account_add: %v", m)
}
aid, _ := m["account_id"].(string)
if aid == "" {
t.Fatalf("account_add 应返回 account_id: %v", m)
}
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
accts := asArr(m["accounts"])
if len(accts) != 1 {
t.Fatalf("应有1个账号: %d", len(accts))
}
a := accts[0].(map[string]interface{})
if a["nickname"] != "我的号" || a["enabled"] != true {
t.Fatalf("账号字段: %v", a)
}
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`)
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" {
t.Fatalf("改名失败")
}
invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`)
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
if n := len(asArr(m["accounts"])); n != 0 {
t.Fatalf("删除后应为0, 实得 %d", n)
}
})
t.Run("模型开关同步上游源", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
aid, _ := m["account_id"].(string)
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
if !ok {
t.Fatal("带凭据账号应已同步源")
}
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
// 关掉一个模型,源里就少一个。
all := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
total := len(asArr(all["models"]))
if n := len(asArr(sm["models"])); n != total {
t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
if n := len(asArr(sm["models"])); n != total-1 {
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
}
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
m = invoke(t, ps, "provider_status", `{}`)
for _, s := range asArr(m["statuses"]) {
x := s.(map[string]interface{})
if x["id"] == "minimax" && x["closed"] != true {
t.Fatalf("全关模型后应 closed=true: %v", x)
}
}
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`)
})
t.Run("enabled开关同步上游源", func(t *testing.T) {
m := invoke(t, ps, "account_list", `{"platform":"minimax"}`)
accts := asArr(m["accounts"])
if len(accts) == 0 {
t.Fatal("上个用例应留有账号")
}
aid := accts[0].(map[string]interface{})["id"].(string)
before := len(listSourcesViaKernel(t, c))
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
}
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
if n := len(listSourcesViaKernel(t, c)); n != before {
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
}
})
t.Run("供应商整体开关", func(t *testing.T) {
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`)
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok {
t.Fatal("关闭渠道后不应有 minimax 源")
}
m := invoke(t, ps, "provider_status", `{}`)
for _, s := range asArr(m["statuses"]) {
x := s.(map[string]interface{})
if x["id"] == "minimax" && x["closed"] != true {
t.Fatalf("关闭后应 closed: %v", x)
}
}
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`)
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
t.Fatal("开启渠道后应有 minimax 源")
}
})
t.Run("备份导出含凭据", func(t *testing.T) {
invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`)
exp := invoke(t, ps, "backup_export", `{}`)
payload, _ := exp["payload"].(map[string]interface{})
if payload == nil {
t.Fatal("backup_export 应返回 payload")
}
creds, _ := payload["credentials"].(map[string]interface{})
found := false
for _, v := range creds {
if v == "CL-KEY" {
found = true
}
}
if !found {
t.Fatalf("备份应含凭据明文: %v", creds)
}
})
}
// TestChannelsExtendedRPCBehavior:对齐参考剩余 RPC 面(credits.status/balances、
// account.reset/retest、model.setDisabledMany/clearDead、provider 排序、
// gateway/backup.status、usage.badgePreference、onboarding)。
func TestChannelsExtendedRPCBehavior(t *testing.T) {
_, ps := poolCore(t)
t.Run("credits_balances_无凭据报错_有凭据解析", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"b1"}`)
aid, _ := m["account_id"].(string)
j := invoke(t, ps, "credits_balances", `{"platform":"minimax"}`)
var row map[string]interface{}
for _, a := range asArr(j["accounts"]) {
x := a.(map[string]interface{})
if x["accountId"] == aid {
row = x
}
}
if row == nil {
t.Fatal("balances 应含该账号")
}
if row["balance"] != nil || row["error"] == nil {
t.Fatalf("无凭据应 error 而非编造余额: %v", row)
}
})
t.Run("credits_status_无端点平台如实null", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"opencode","nickname":"oc1"}`)
aid, _ := m["account_id"].(string)
j := invoke(t, ps, "credits_status", `{"platform":"opencode"}`)
for _, a := range asArr(j["accounts"]) {
x := a.(map[string]interface{})
if x["accountId"] == aid && x["status"] != nil {
t.Fatalf("opencode 无状态端点应 null: %v", x)
}
}
})
t.Run("account_reset_clears", func(t *testing.T) {
invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"r1"}`)
j := invoke(t, ps, "account_reset", `{"platform":"minimax","account_id":"__all__"}`)
if j["ok"] != true {
t.Fatalf("resetAll: %v", j)
}
})
t.Run("model_set_disabled_many", func(t *testing.T) {
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":true}`)
j := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
off := 0
for _, x := range asArr(j["models"]) {
mm := x.(map[string]interface{})
if mm["disabled"] == true {
off++
}
}
if off != 2 {
t.Fatalf("批量关2模型后应2个disabled, 实得 %d", off)
}
invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`)
})
// model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的
// "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。
//
// 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上
// 会删掉可用模型(参考文件头有实测教训),已弃用。
// 新语义的完整判据在 channels_dead_model_test.go。
t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) {
// 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么)
j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
if j["ok"] != true {
t.Fatalf("model_clear_dead 应成功: %v", j)
}
if n, _ := j["removed"].(float64); n != 0 {
t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"])
}
// 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`)
ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
off := 0
for _, m := range asArr(ml["models"]) {
if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true {
off++
}
}
if off != 1 {
t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`)
})
t.Run("provider_order", func(t *testing.T) {
invoke(t, ps, "provider_set_order", `{"order":["minimax","trae","bogus"]}`)
j := invoke(t, ps, "provider_get_order", `{}`)
order := asArr(j["order"])
if len(order) != 2 {
t.Fatalf("bogus 应被过滤, 实得 %v", order)
}
if order[0] != "minimax" {
t.Fatalf("顺序应保留: %v", order)
}
})
t.Run("backup_status", func(t *testing.T) {
invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"s1"}`)
j := invoke(t, ps, "backup_status", `{}`)
if n, _ := j["accounts"].(float64); n < 1 {
t.Fatalf("backup_status 应统计账号: %v", j)
}
})
t.Run("badge_preference_非法值拒绝", func(t *testing.T) {
j := invoke(t, ps, "usage_badge_preference", `{"preference":"bogus"}`)
if j["error"] == nil {
t.Fatalf("非法偏好应拒绝: %v", j)
}
j = invoke(t, ps, "usage_badge_preference", `{"preference":"credits"}`)
if j["preference"] != "credits" {
t.Fatalf("合法偏好应保存: %v", j)
}
})
t.Run("onboarding_不支持平台如实报", func(t *testing.T) {
j := invoke(t, ps, "onboarding_status", `{"platform":"minimax"}`)
if j["supported"] != false {
t.Fatalf("minimax 无新人任务应 supported=false: %v", j)
}
})
t.Run("permanent_lock_读写", func(t *testing.T) {
j := invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy","locked":true}`)
if j["locked"] != true {
t.Fatalf("锁定应生效: %v", j)
}
j = invoke(t, ps, "credits_permanent_lock", `{"platform":"buddy"}`)
if j["locked"] != true {
t.Fatalf("只读应回显锁定: %v", j)
}
})
}
// TestBuddyBillingHeadersMatchReference 锁定 buddy 计费请求的头(逐行移植参考
// checkinHeaders)。真实缺陷:实现只发 Authorization,上游直接 401 —— 修好后
// 账号能读到凭据但余额仍失败,运维看到的仍是「拿不到账号信息」。
func TestBuddyBillingHeadersMatchReference(t *testing.T) {
_, ps := poolCore(t)
var got map[string]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = map[string]string{}
for k := range r.Header {
got[k] = r.Header.Get(k)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"totalCredit":42}}`)
}))
defer ts.Close()
// 把 buddy 的余额/签到端点指到本地假上游
invoke(t, ps, "set_endpoints", fmt.Sprintf(
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
m := invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
if m["ok"] != true {
t.Fatalf("account_add: %v", m)
}
invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
if got == nil {
t.Fatal("余额请求未发出")
}
// 参考 checkinHeaders 的必备身份头(缺一个上游就 401)
want := map[string]string{
"Authorization": "Bearer BD-AT",
"X-Domain": "copilot.tencent.com",
"X-Product": "SaaS",
"X-Product-Code": "codebuddy",
"User-Agent": "CodeBuddyIDE/1.106.1",
}
for k, v := range want {
if got[k] != v {
t.Errorf("头 %s = %q, 期望 %q(buddy 计费端点缺它会被上游 401)", k, got[k], v)
}
}
}
// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径:
// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同,
// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain ——
// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。
func TestBuddyBalanceParseDoubleNested(t *testing.T) {
_, ps := poolCore(t)
// 两个有效包 + 一个失效包(ExpiredTime 在 2020 年)
body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[
{"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"},
{"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"},
{"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"}
]}}}}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer ts.Close()
invoke(t, ps, "set_endpoints", fmt.Sprintf(
`{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL))
invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`)
j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
var row map[string]interface{}
for _, a := range asArr(j["accounts"]) {
row = a.(map[string]interface{})
}
bal, _ := row["balance"].(map[string]interface{})
if bal == nil {
t.Fatalf("应解析出余额, 实得 error=%v", row["error"])
}
// 100.5 + 55.17 = 155.67(失效包的 500 不计入)
if got, _ := bal["total"].(float64); got != 155.67 {
t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"])
}
// 单层 data(缺 Response 层)必须判为解析失败,不能当 0
body = `{"code":0,"data":{"TotalDosage":609}}`
j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`)
for _, a := range asArr(j["accounts"]) {
row = a.(map[string]interface{})
}
if row["balance"] != nil {
t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row)
}
}

View File

@ -1,86 +0,0 @@
package core
import (
"strings"
"testing"
)
// 三个"私有协议"平台的源注册必须引用**插件自装**的适配器。
//
// 为什么单独测这一条:适配器装上了 ≠ 源用了它。"装了但源还指向 openai"
// 是这类改动最可能的漏配,且**不会报错** —— 只是签名/信封不生效、上游返回
// 流内错误或 401,看起来像"平台不稳定"。
//
// 三个平台的失败模式各不相同,但根因是同一个(平台协议差异被当成适配器名差异):
//
// codearts → 华为云 AK/SK 签名
// trae → SOLO 载荷格式(config_name/function)
// gemini → 私有 /v1internal: 信封 + 身份五头
func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
c, ps := poolCore(t)
cases := []struct {
platform string
wantAdapter string
wantBaseHas string
credential string
}{
{"codearts", "codearts",
"snap-access.cn-north-4.myhuaweicloud.com",
`{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`},
{"trae", "channels-trae",
"trae-api-cn.mchost.guru",
`{"access_token":"TRAE-TOK","refresh_token":"RT"}`},
{"gemini", "channels-gemini",
"cloudcode-pa.googleapis.com",
`{"access_token":"GEM-TOK","refresh_token":"GRT"}`},
}
for _, tc := range cases {
t.Run(tc.platform, func(t *testing.T) {
m, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": tc.platform, "credential": tc.credential,
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
if !ok {
names := []string{}
for _, x := range srcs {
names = append(names, x["name"].(string))
}
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
"凭据形状不符或内核权限未授", tc.platform, names)
}
ad, _ := sm["adapter"].(string)
if ad != tc.wantAdapter {
t.Errorf("源 adapter = %q,应为插件自装的 %q"+
"(否则平台协议转换不生效,且不报错)", ad, tc.wantAdapter)
}
bu, _ := sm["base_url"].(string)
if !strings.Contains(bu, tc.wantBaseHas) {
t.Errorf("base_url = %q,应含 %q", bu, tc.wantBaseHas)
}
// 模型必须进源(否则账号看起来接入了但一个模型都调不到)
var nModels int
switch mv := sm["models"].(type) {
case []string:
nModels = len(mv)
case []interface{}:
nModels = len(mv)
}
if nModels == 0 {
t.Errorf("%s 源没有任何模型(%T)", tc.platform, sm["models"])
}
})
}
}

View File

@ -1,88 +0,0 @@
package core
import (
"strings"
"testing"
"llmsproxy/internal/lua"
)
// qoderReason 调真实的 transform_error 钩子(不自己重算)。
func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string {
t.Helper()
reason, ok, err := vm.TransformError("channels-qoder", status, body)
if err != nil {
t.Fatalf("transform_error 调用失败: %v", err)
}
if !ok {
t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body)
}
return reason
}
// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**:
//
// 10605 排队 → 暂时受阻,等一下就好
// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次)
//
// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` ——
// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**,
// 厂商码值留在插件域(内核零厂商名)。
func TestQoderAdapterParsesQueueAndBilling(t *testing.T) {
c, _ := poolCore(t)
vm := c.VM()
// ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状)
out := qoderReason(t, vm, 403,
`{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`)
if !strings.Contains(out, "排队") {
t.Errorf("10605 必须说成排队,实得 %q", out)
}
// 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到)
if !strings.Contains(out, "30") {
t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out)
}
if !strings.Contains(out, "12") {
t.Errorf("应从内层取到 queueCount=12,实得 %q", out)
}
// ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。
// ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、
// SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。
out2 := qoderReason(t, vm, 200,
`{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`)
if !strings.Contains(out2, "排队") {
t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2)
}
if !strings.Contains(out2, "8") {
t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2)
}
// ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用"
out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`)
if !strings.Contains(out3, "额度") {
t.Errorf("110 必须说成额度耗尽,实得 %q", out3)
}
if strings.Contains(out3, "排队") {
t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3)
}
if !strings.Contains(out3, "重试无用") {
t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3)
}
// ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误
//(关键词必须窄 —— 宽词会把模型正常输出误判成故障)
out4 := qoderReason(t, vm, 500,
`{"error":{"message":"the response discusses quota and balance concepts"}}`)
if strings.Contains(out4, "额度已用尽") {
t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4)
}
// ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事
//(否则这个专用适配器就没有存在理由)
gen, okGen, _ := vm.TransformError("openai", 403,
`{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`)
if okGen && strings.Contains(gen, "排队") {
t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要")
}
}

View File

@ -1,222 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"llmsproxy/internal/lua"
)
// Qoder 活动领取有三条参考用**抓包 + 用户报障**定位过的「会误报」陷阱,
// 全部必须在判据里钉死:
//
// ① 「无可领活动」必须是 inactive,**不能**报 already-claimed
// (用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」);
// ② 刷新前(每日 10:00 UTC+8 之前)看到 CLAIMED 属于**昨天** → 必须报 inactive
// 而不是"今天已领取"(谎报且不可逆);
// ③ 未开通要给出 action_required 的可操作提示。
//
// 另:`replayed:true` 是幂等判据(HTTP 仍是 200),只看状态码会把"今天已领"
// 误报成"成功 +100"。
func TestQoderClaimDecisionTable(t *testing.T) {
type c struct {
name string
campaigns string
usage string
wantKind string
wantAction bool
wantClaimed int
}
cases := []c{
{
"无可领且无 CLAIM_BENEFIT,addOnQuota 缺失→inactive+可操作提示",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"VIEW_DETAILS","claimStatus":"CLAIMABLE"}]}`,
`{"data":{"userQuota":{"remaining":0}}}`,
"inactive", true, 0,
},
{
"无可领但确实领过(CLAIMED)→ 视刷新时刻",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMED"}]}`,
`{"data":{"addOnQuota":{"remaining":0}}}`,
"", false, 0, // 见下方:按当前 UTC+8 时刻断言二者之一
},
{
"可领→success(多个活动逐个领)",
`{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}},
{"campaignId":"C2","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":50}}]}`,
"",
"success", false, 2,
},
{
"可领但服务端回放 replayed→already-claimed(不是 success)",
`{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`,
"",
"already-claimed", false, 1,
},
{
"无任何活动且已开通(addOnQuota 存在)→inactive(不带可操作提示)",
`{"showCampaign":false,"claimable":false,"campaigns":[]}`,
`{"data":{"addOnQuota":{"total":100,"remaining":0}}}`,
"inactive", false, 0,
},
{
"有 CLAIM_BENEFIT 但已过期→inactive",
`{"showCampaign":true,"claimable":false,"campaigns":[{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"EXPIRED"}]}`,
`{"data":{"addOnQuota":{"remaining":0}}}`,
"inactive", false, 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claimed := 0
var claimBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/sash/api/v1/me/campaigns":
fmt.Fprint(w, tc.campaigns)
case r.URL.Path == "/sash/api/v2/me/usage":
if tc.usage == "" {
w.WriteHeader(500)
return
}
fmt.Fprint(w, tc.usage)
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
claimed++
b := make([]byte, 0)
if r.ContentLength > 0 {
b = make([]byte, r.ContentLength)
_, _ = r.Body.Read(b)
}
claimBody = string(b)
if tc.name == "可领但服务端回放 replayed→already-claimed(不是 success)" {
fmt.Fprint(w, `{"replayed":true,"status":"CLAIMED"}`)
} else {
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
}
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
cred, _ := json.Marshal(map[string]interface{}{"access_token": "AT-1", "uid": "u1"})
body, _ := json.Marshal(map[string]string{
"platform": "qoder", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome,实得 %v", j)
}
kind, _ := oc["kind"].(string)
if tc.wantKind == "" {
// 刷新时刻决定 already-claimed 还是 inactive —— 两者都合法,
// 但**绝不能**是 success(今天是否领过不能靠"看到 CLAIMED"推断成可以再领)。
if kind != "already-claimed" && kind != "inactive" {
t.Fatalf("刷新前/后都应报 already-claimed 或 inactive,实得 %q(%v)", kind, oc["message"])
}
} else if kind != tc.wantKind {
t.Fatalf("kind 应为 %s,实得 %q(%v)", tc.wantKind, kind, oc["message"])
}
if tc.wantAction && oc["action_required"] != true {
t.Errorf("未开通场景应带 action_required=true(提示去官方客户端),实得 %v", oc["action_required"])
}
if claimed != tc.wantClaimed {
t.Errorf("claim 请求次数 = %d,期望 %d", claimed, tc.wantClaimed)
}
if tc.wantClaimed > 0 && claimBody != "" {
t.Errorf("claim 请求体必须是空串(抓包实测 content-length: 0),实得 %q", claimBody)
}
})
}
}
// 「今天的活动列表是否已刷新」必须用 **UTC+8 算术平移**判定。
// ⚠️ 本机时区恰是 HKT(+0800),所以「用本机时区判」的实现在真实时刻下**结果相同**
// —— 那是判据不可达(这台机器上永远抓不到)。必须注入 UTC 时刻来覆盖两个方向:
// 判据的输入是 UTC 毫秒,与运行环境时区无关。
func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// UTC 01:00 = UTC+8 09:00 → **未刷新**(阈值 10:00)
// UTC 02:00 = UTC+8 10:00 → **已刷新**
// 若实现误用本机时区(HKT=UTC+8),对"UTC 毫秒"这个输入会得到:
// 把 07:00Z 当本地 15:00 → 判已刷新(错)
cases := []struct {
name string
utcHour int
want bool
}{
{"UTC 01:00(UTC+8 09:00,未到 10:00)", 1, false},
{"UTC 02:00(UTC+8 10:00,刚好刷新)", 2, true},
{"UTC 00:30(UTC+8 08:30,未刷新)", 0, false},
{"UTC 15:00(UTC+8 23:00,已刷新)", 15, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// 构造该 UTC 时刻的毫秒值(固定日期,避免跨日干扰)
// 用 time.Date 明确构造「00:00 UTC + N 小时」——手算 epoch 会引入
// 时区/跨日误差(第一版就是这么错的:基准不是 UTC 零点)。
ms := time.Date(2025, 10, 9, tc.utcHour, 0, 0, 0, time.UTC).UnixMilli()
var rec map[string]interface{}
got, err := ps.Call("channels", "__qoder_refresh_probe", map[string]interface{}{
"now_ms": float64(ms),
})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
rec, _ = w["json"].(map[string]interface{})
if rec == nil {
t.Fatalf("无 json: %#v", got)
}
if rec["refreshed"] != tc.want {
t.Fatalf("refreshed 应为 %v,实得 %v(UTC+8 阈值 10:00)", tc.want, rec["refreshed"])
}
})
}
}

View File

@ -1,84 +0,0 @@
package core
import (
"os"
"path/filepath"
"testing"
)
// Qoder 的设备身份两个头**必须成对**出现:参考用抓包 + 消融实验定位过 ——
// 缺任一个,服务端就不下发「可领取」的活动(只回 VIEW_DETAILS),
// 症状是用户报的「没领过却显示今日已领取」。
// 同时:拿不到身份时必须**保守降级**(照常发请求、不带这两个头),
// 不能让纯插件登录(未装桌面端)的账号整个积分功能报错。
func TestQoderMachineIdentityPairingAndDegradation(t *testing.T) {
t.Run("行1 有 machine_token.json → 成对带上两个头", func(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "machine_token.json")
if err := os.WriteFile(p, []byte(`{"token":"T-abc","type":"TY-def","updateAt":1}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("QODER_MACHINE_TOKEN_PATH", p)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe: %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != "T-abc" || m["type"] != "TY-def" {
t.Fatalf("应从磁盘缓存读出 token/type,实得 %v", m)
}
if m["client_type"] != "10" {
t.Fatalf("Cosy-ClientType 应为 10(参考实测值),实得 %v", m["client_type"])
}
})
t.Run("行2 无任何来源 → 降级:不带这两个头,且不报错", func(t *testing.T) {
// 指向不存在的路径,且把 HOME 指到空目录(找不到 runtime-info)
t.Setenv("QODER_MACHINE_TOKEN_PATH", filepath.Join(t.TempDir(), "nope.json"))
t.Setenv("HOME", t.TempDir())
t.Setenv("APPDATA", "")
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
got, err := ps.Call("channels", "__qoder_machine_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("probe 不应报错(必须保守降级): %v", err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("无 json: %#v", got)
}
if m["token"] != nil && m["token"] != "" {
t.Errorf("拿不到身份时不应带 token,实得 %v", m["token"])
}
if m["type"] != nil && m["type"] != "" {
t.Errorf("拿不到身份时不应带 type,实得 %v", m["type"])
}
// 头仍然要有 clienttype(那是必要但不充分条件,与身份无关)
if m["client_type"] != "10" {
t.Errorf("降级时仍应带 Cosy-ClientType=10,实得 %v", m["client_type"])
}
})
}

View File

@ -1,181 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// qoderHarness:Qoder PKCE 设备码轮的测试台。
//
// 参考语义(qoder-oauth.ts / qoder.ts 注释点名):
//
// ① 授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
// &nonce&machine_id&client_id —— **本地拼装,不打网络**
// ② 轮询 = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
// ③ 404 = 尚无会话(继续等);2xx 无 token = 继续等;有 token 即完成
//
// 此前的实现在这里标了 flow="manual",理由是"登录要 WASM 签名头"——
// 把聊天加密与登录混为一谈,导致该平台完全不可用。
type qoderHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
poll int32 // 已发生的轮询次数
// step: 第几次轮询开始返回 token(0 = 一直 404)
tokenAt int32
gotQuery string
}
func newQoderHarness(t *testing.T) *qoderHarness {
t.Helper()
h := &qoderHarness{t: t, tokenAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/deviceToken/poll" {
h.gotQuery = r.URL.RawQuery
n := atomic.AddInt32(&h.poll, 1)
if n < atomic.LoadInt32(&h.tokenAt) {
w.WriteHeader(404) // 尚无会话
fmt.Fprint(w, `{"code":404,"msg":"no session"}`)
return
}
fmt.Fprint(w, `{"token":"QD-AT-1","refresh_token":"QD-RT-1","user_id":"u9","user_name":"qoder-dev"}`)
return
}
w.WriteHeader(404)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
// 把 poll 指到假上游(授权页是本地拼装,不经网络)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"device": map[string]interface{}{"token_url": h.ts.URL + "/api/v1/deviceToken/poll"},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *qoderHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *qoderHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestQoderDevicePollBehavior 逐行锁定参考的 Qoder 登录语义。
func TestQoderDevicePollBehavior(t *testing.T) {
h := newQoderHarness(t)
t.Run("行1 授权页本地拼装_含PKCE四要素", func(t *testing.T) {
j, _ := h.begin()
url, _ := j["url"].(string)
for _, want := range []string{
"qoder.com/device/selectAccounts",
"challenge=", "challenge_method=S256", "nonce=", "machine_id=",
// client_id 必须是 prod 那个(非 prod 会在授权回调阶段被拒)
"client_id=e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb",
} {
if !strings.Contains(url, want) {
t.Fatalf("授权页缺 %q:\n%s", want, url)
}
}
})
t.Run("行2 404视为继续_不误报失败", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99) // 一直 404
_, sid := h.begin()
for i := 0; i < 3; i++ {
st := h.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("404 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 轮询带verifier与nonce_不带device_code", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 99)
_, sid := h.begin()
h.pollOnce(sid)
q := h.gotQuery
if !strings.Contains(q, "verifier=") || !strings.Contains(q, "nonce=") {
t.Fatalf("轮询 query 应含 verifier 与 nonce, 实得 %q", q)
}
if !strings.Contains(q, "challenge_method=S256") {
t.Fatalf("轮询应带 challenge_method=S256, 实得 %q", q)
}
})
t.Run("行4 拿到token即完成_落凭据", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 2) // 第二次轮询返回 token
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("拿到 token 后应 completed, 实得 %v", st)
}
// 凭据落保险库(旧路径键)
v, ok, _ := h.ps.GetSecret("channels", "channels:qoder")
if !ok || !strings.Contains(v, "QD-AT-1") {
t.Fatalf("凭据应含 QD-AT-1, 实得 %q", v)
}
})
t.Run("行5 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.poll, 0)
atomic.StoreInt32(&h.tokenAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"qoder"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"qoder","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:qoder:"+aid); !ok {
t.Fatalf("池路径凭据未落到 channels:qoder:%s", aid)
}
})
}

View File

@ -1,165 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// raccoonHarness:Raccoon 内置二维码扫码登录的测试台。
//
// 参考语义(raccoon-oauth.ts 点名):
//
// ① qrcode_code **本地随机生成**(16 字节 hex),服务端接受任意自造 code
// ② 轮询 POST /login_with_qrcode_code {qrcode_code}
// → data.status ∈ pending / logging / canceled / success
// ③ success 但**缺 token** 视为未完成(否则产出空凭据并卡死)
// ④ **任何异常降级为 pending**(网络抖动/畸形响应/未知 status)
type raccoonHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
step int32 // 第几次轮询开始 success
gotCode string
}
func newRaccoonHarness(t *testing.T) *raccoonHarness {
t.Helper()
h := &raccoonHarness{t: t, step: 3}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/login_with_qrcode_code") {
h.polls++
if h.polls < atomic.LoadInt32(&h.step) {
fmt.Fprint(w, `{"code":0,"data":{"status":"logging"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"success","access_token":"RC-AT-1","refresh_token":"RC-RT-1"}}`)
return
}
w.WriteHeader(404)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "raccoon",
"qr": map[string]interface{}{
"api_base": h.ts.URL,
"auth_prefix": "/api/web/auth/v1",
"poll_path": "/login_with_qrcode_code",
"login_page": "/login/mp",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *raccoonHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"raccoon"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *raccoonHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestRaccoonQrPollBehavior 逐行锁定参考的扫码登录语义。
func TestRaccoonQrPollBehavior(t *testing.T) {
h := newRaccoonHarness(t)
t.Run("行1 本地生成code并给出二维码URL", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
qr, _ := j["qr_url"].(string)
if !strings.Contains(qr, "code=") || !strings.Contains(qr, "login/mp") {
t.Fatalf("应给出带 code 的二维码 URL, 实得 %q", qr)
}
// code 应是 32 位 hex(本地随机生成)
code := qr[strings.Index(qr, "code=")+5:]
if i := strings.IndexAny(code, "&"); i > 0 {
code = code[:i]
}
if len(code) != 32 {
t.Fatalf("qrcode_code 应是 32 位 hex, 实得 %q", code)
}
})
t.Run("行2 logging保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.step, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("logging 应 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 success且带token才完成", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.step, 2)
_, sid := h.begin()
h.pollOnce(sid)
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("success+token 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:raccoon")
if !ok || !strings.Contains(v, "RC-AT-1") {
t.Fatalf("凭据应含 RC-AT-1, 实得 %q", v)
}
})
t.Run("行4 canceled是终态失败", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"data":{"status":"canceled"}}`)
})
hh.ps.SetSecret("channels", "channels:raccoon", "")
_, sid := hh.begin()
if st := hh.pollOnce(sid); st["state"] != "failed" {
t.Fatalf("canceled 应 failed, 实得 %v", st["state"])
}
})
t.Run("行5 success缺token视为未完成", func(t *testing.T) {
hh := newRaccoonHarness(t)
hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"code":0,"data":{"status":"success"}}`)
})
_, sid := hh.begin()
st := hh.pollOnce(sid)
if st["state"] != "pending" {
t.Fatalf("缺 token 的 success 必须 pending(否则产出空凭据并卡死), 实得 %v", st["state"])
}
})
}

View File

@ -1,193 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
//
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
// 这段代码。
//
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
func TestAccountRefreshSendsRealRequest(t *testing.T) {
var hits int32
var gotBody, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
gotPath = r.URL.Path
b := make([]byte, 1024)
n, _ := r.Body.Read(b)
gotBody = string(b[:n])
w.Header().Set("Content-Type", "application/json")
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
}))
defer srv.Close()
_, ps := poolCore(t)
// 注入一个测试平台(夹具绝不写进生产源码)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入平台: %v", err)
}
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfptest", "account_id": "a1",
})
if err != nil {
t.Fatalf("account_refresh: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
if n := atomic.LoadInt32(&hits); n != 1 {
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
}
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
t.Errorf("请求路径不对: %s", gotPath)
}
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
var sent map[string]interface{}
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
}
if _, ok := sent["refreshToken"]; !ok {
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
}
if sent["grantType"] != "refresh_token" {
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
}
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
if j["refreshed"] != true {
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
}
if j["ok"] != true {
t.Errorf("应报 ok=true,实得 %v", j["ok"])
}
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:rfptest",
})
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
cred, _ := rj["credential"].(string)
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
}
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
if !strings.Contains(cred, "NEW-REFRESH") {
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
}
}
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
func TestAccountRefreshReportsFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
}))
defer srv.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpfail", "account_id": "a1",
})
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
if j["refreshed"] == true {
t.Error("续期失败时绝不能报 refreshed=true")
}
msg, _ := j["message"].(string)
if !strings.Contains(msg, "401") {
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
}
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
"credential": `{"access_token":"OLD"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpnort", "account_id": "a1",
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
m2, _ := j2["message"].(string)
if !strings.Contains(m2, "重新登录") {
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
}
}
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
}))
defer srv.Close()
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
}); err != nil {
t.Fatalf("注入: %v", err)
}
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
"platform": "rfpexp", "account_id": "a1",
}); err != nil {
t.Fatalf("account_refresh: %v", err)
}
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
"ref": "channels:rfpexp",
})
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
cred, _ := rj["credential"].(string)
var m map[string]interface{}
_ = json.Unmarshal([]byte(cred), &m)
ea, _ := m["expires_at"].(string)
if ea == "" {
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
}
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
if ea == "7200" {
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
}
if len(ea) < 9 {
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
}
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
accounts := asArrInterface(aj["accounts"])
if len(accounts) == 0 {
t.Fatal("账号列表为空")
}
first, _ := accounts[0].(map[string]interface{})
idx, _ := first["expiresAt"].(float64)
if idx == 0 {
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
}
// 且应当是**绝对**秒级时刻(不是相对 7200)
if idx > 0 && idx < 1e9 {
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
}
}

View File

@ -1,125 +0,0 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// 账号池落盘的**原子性与一致性**(照参考 account-pool.ts 的真实缺陷)。
//
// 参考的缺陷:`removeAccount` 只保证「读→改」之间无 await,而内部的
// `await store.save(...)` 是**异步**的 ⇒ 两个写者各自的 save 可以**乱序完成**,
// 后完成者带着它更早的快照覆盖磁盘。实测形态:
// - 并发删 a / 删 b(先发起的后完成)⇒ **已删除的账号在磁盘上复活**;
// - 删 victim 与加 added 交错 ⇒ **新增账号被静默丢失**。
//
// 我们的实现是**同步** tmp+rename(Lua 无异步),且每次写都序列化**当前**
// `plugin.state`(不是调用方早先取的快照)⇒ 该竞态结构上不可能发生。
// 但"不可能"必须被验证,否则只是一句自信。
func TestAccountPoolStoreIsAtomicAndConsistent(t *testing.T) {
c, ps := poolCore(t)
// 找出插件的存储目录(内核给的 ground truth,不是猜的)
dirOut, err := ps.Call("channels", "__store_dir_probe", nil)
if err != nil {
t.Fatalf("store_dir_probe: %v", err)
}
dj, _ := dirOut.(map[string]interface{})["json"].(map[string]interface{})
dir, _ := dj["store_dir"].(string)
if dir == "" {
t.Fatalf("拿不到插件存储目录,实得 %#v", dirOut)
}
storePath := filepath.Join(dir, ".channels-store.json")
t.Logf("插件存储目录: %s", dir)
// ⚠️ 必须在**测试目录**里,绝不能是生产路径
if _, err := os.Stat("/etc/llmsproxy/plugins/.channels-store.json"); err == nil {
if filepath.Clean(dir) == "/etc/llmsproxy/plugins" {
t.Fatal("测试正在使用生产存储目录 —— 隔离失效(这会造成真实数据事故)")
}
}
// 连续做一组"读-改-写"(模拟并发操作最终落在同一份文档上)
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
"id": "atomic1", "style": "google", "credential": `{"access_token":"a"}`}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"a", "b", "c"} {
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "atomic1", "account_id": id}); err != nil {
t.Fatalf("add %s: %v", id, err)
}
}
// 删 a、删 b(参考的缺陷场景)
for _, id := range []string{"a", "b"} {
if _, err := ps.Call("channels", "account_delete", map[string]interface{}{
"platform": "atomic1", "account_id": id}); err != nil {
t.Fatalf("delete %s: %v", id, err)
}
}
// 再加一个(参考的第二个缺陷场景:与删除交错的写)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "atomic1", "account_id": "d"}); err != nil {
t.Fatalf("add d: %v", err)
}
// 内存视图
mem, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "atomic1"})
mj, _ := mem.(map[string]interface{})["json"].(map[string]interface{})
memIDs := map[string]bool{}
for _, a := range asArrInterface(mj["accounts"]) {
if am, ok := a.(map[string]interface{}); ok {
if id, ok := am["id"].(string); ok {
memIDs[id] = true
}
}
}
// 磁盘视图:必须是**合法 JSON**(tmp+rename 的原子性保证:绝无半截文件)
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("读存储: %v", err)
}
var disk map[string]interface{}
if err := json.Unmarshal(b, &disk); err != nil {
t.Fatalf("磁盘文档不是合法 JSON(原子替换失效,出现半截文件): %v", err)
}
// 文档形状:{version, state:{pool, accounts, ...}} —— pool 在 state 之下。
st, _ := disk["state"].(map[string]interface{})
if st == nil {
t.Fatalf("磁盘文档缺 state 段,实得 %v", disk)
}
pool, _ := st["pool"].(map[string]interface{})
diskIDs := map[string]bool{}
if pl, ok := pool["atomic1"].(map[string]interface{}); ok {
for id := range pl {
diskIDs[id] = true
}
}
// ① 已删除的账号**不得**在磁盘上复活(参考的第一个缺陷形态)
for _, gone := range []string{"a", "b"} {
if diskIDs[gone] {
t.Errorf("已删除的账号 %q 在磁盘上复活了(参考的并发写竞态形态)", gone)
}
if memIDs[gone] {
t.Errorf("已删除的账号 %q 仍在内存视图里", gone)
}
}
// ② 新增的账号**不得**被静默丢失(参考的第二个缺陷形态)
if !diskIDs["d"] {
t.Error("新增账号 d 在磁盘上缺失(被静默丢失 —— 参考的竞态形态)")
}
// ③ 内存与磁盘必须一致(分叉本身就是缺陷)
for id := range memIDs {
if !diskIDs[id] {
t.Errorf("账号 %q 在内存里有、磁盘上没有(内存/磁盘分叉)", id)
}
}
for id := range diskIDs {
if !memIDs[id] {
t.Errorf("账号 %q 在磁盘上有、内存里没有(内存/磁盘分叉)", id)
}
}
_ = c
}

View File

@ -1,222 +0,0 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
//
// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
// 为纯内存,正解是插件自持文档。
//
// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
func TestChannelsStoreIsPluginOwned(t *testing.T) {
// 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
// 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
dir := t.TempDir()
t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
load := func() *lua.Plugins {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "list_sources", "remove_source", "status"}, false)
return ps
}
ps := load()
// 加账号 → 状态变更 → 应立即落盘
add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
})
if err != nil {
t.Fatalf("account_add: %v", err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
// ① 插件自有存储文件必须存在
storePath := filepath.Join(dir, ".channels-store.json")
b, err := os.ReadFile(storePath)
if err != nil {
t.Fatalf("插件自有存储未写出(%s): %v\n"+
" —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
}
var saved struct {
State map[string]interface{} `json:"state"`
}
if err := json.Unmarshal(b, &saved); err != nil {
t.Fatalf("存储文件不是合法 JSON: %v", err)
}
pool, _ := saved.State["pool"].(map[string]interface{})
buddy, _ := pool["buddy"].(map[string]interface{})
if _, ok := buddy[aid]; !ok {
t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
}
// ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
}
raw := string(b)
for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
if strings.Contains(raw, bad) {
t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
}
}
// ③ 新实例(模拟重启)必须读回账号
ps2 := load()
lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatalf("account_list: %v", err)
}
found := false
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
if raw.(map[string]interface{})["id"] == aid {
found = true
}
}
if !found {
t.Fatalf("重启后账号 %s 未恢复 —— 持久化未生效", aid)
}
}
// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
//
// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
// 写失败还删它,等于用一次迁移把数据弄丢。
func TestChannelsMigratesLegacyKernelState(t *testing.T) {
// 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
dir := ""
// 造一个内核遗留文件(v2.0.4 的形状)
legacy := map[string]interface{}{
"version": 1,
"state": map[string]interface{}{
"accounts": map[string]interface{}{},
"log": []interface{}{},
"pool": map[string]interface{}{
"buddy": map[string]interface{}{
"legacy-acct": map[string]interface{}{
"id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
"enabled": true, "credentialRef": "channels:buddy:legacy-acct",
"createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
},
},
},
},
}
b, _ := json.Marshal(legacy)
c := kernelTestCore(t)
dir = c.Config().PluginDir
legacyPath := filepath.Join(dir, ".channels.state.json")
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
t.Fatal(err)
}
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// ① 遗留账号必须被迁移进内存(可见)
lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
found := false
for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
if raw.(map[string]interface{})["id"] == "legacy-acct" {
found = true
}
}
if !found {
t.Fatal("遗留账号未迁移 —— 升级即丢账号")
}
// ② 自有存储必须已写出
if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
t.Fatalf("迁移后自有存储应存在: %v", err)
}
// ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatal("遗留内核文件应被删除,否则下次启动会被它覆盖")
}
}
// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
//
// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
// 强制写失败的手段:把 persistState 要写的临时文件路径
// (<dir>/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
// 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
sub := t.TempDir()
t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
legacy := map[string]interface{}{
"version": 1,
"state": map[string]interface{}{
"pool": map[string]interface{}{
"buddy": map[string]interface{}{
"keepme": map[string]interface{}{
"id": "keepme", "provider": "buddy",
"credentialRef": "channels:buddy:keepme", "createdAt": 1,
},
},
},
},
}
b, _ := json.Marshal(legacy)
legacyPath := filepath.Join(sub, ".channels.state.json")
if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
t.Fatal(err)
}
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
_ = ps.LoadSource("channels", src)
// 写失败的态下,遗留文件必须原样保留
if _, err := os.Stat(legacyPath); err != nil {
t.Fatalf("写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
}
// 且自有存储不应存在(写失败了)
if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
t.Fatal("tmp 路径被占住时不应写出自有存储")
}
}

View File

@ -1,264 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
)
// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。
func mustBox(t *testing.T, runtimeFile string) *config.SecretBox {
t.Helper()
box, err := config.NewSecretBox(runtimeFile)
if err != nil {
t.Fatal(err)
}
return box
}
// channelsCore 起一个带 channels 插件、可指定源码与 box 的核心(压力测试用)。
func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
t.Helper()
c := kernelTestCore(t)
ps := c.Plugins()
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
ps.SetSecretBox(mustBox(t, filepath.Join(t.TempDir(), "runtime.json")))
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
return c, ps
}
// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
const cookieishPlatformFixture = `[{
"id": "cookieish",
"name": "Cookie 平台(测试夹具)",
"flow": "paste",
"base_url": "https://api.cookieish.example/v1",
"adapter": "openai",
"models": ["c-model-1"],
"checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"}
}]`
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
//
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
// 凭据互不可见、最终都 completed),真吞吐压力在 e2e 层(真二进制+HTTP 并发)。
func TestChannelsConcurrentLogins(t *testing.T) {
var hits int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
n := atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"device_code":"DEV-%d","user_code":"UC-%d","verification_uri":"https://example/activate","interval":1,"expires_in":600}`, n, n)
case "/oauth2/token":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"access_token":"TOK","refresh_token":"RF","nickname":"u"}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
_, ps := channelsCore(t, mustChannelsSrc(t))
// 把 minimax 端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "minimax",
"device": map[string]interface{}{
"code_url": ts.URL + "/oauth2/device/code",
"token_url": ts.URL + "/oauth2/token",
},
}); err != nil {
t.Fatal(err)
}
const rounds = 12
var wg sync.WaitGroup
var failures int32
for i := 0; i < rounds; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"minimax"}`,
})
if err != nil {
t.Errorf("start_login #%d: %v", n, err)
atomic.AddInt32(&failures, 1)
return
}
j := out.JSON.(map[string]interface{})
sess, _ := j["session"].(map[string]interface{})
if sess == nil {
t.Errorf("start_login #%d 无会话: %v", n, j)
atomic.AddInt32(&failures, 1)
return
}
sid := sess["id"].(string)
for k := 0; k < 10; k++ {
out2, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid},
})
m2 := out2.JSON.(map[string]interface{})
if m2["state"] == "completed" {
return
}
if m2["state"] == "failed" {
t.Errorf("会话 #%d failed: %v", n, m2)
atomic.AddInt32(&failures, 1)
return
}
}
t.Errorf("会话 #%d 10 次轮询未终态", n)
atomic.AddInt32(&failures, 1)
}(i)
}
wg.Wait()
if failures != 0 {
t.Fatalf("并发登录失败 %d 轮", failures)
}
}
// TestChannelsClaimStress 压力:同一平台并发领签到(幂等 + 无登录态丢失)。
func TestChannelsClaimStress(t *testing.T) {
var claims int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/checkin" {
atomic.AddInt32(&claims, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true,"credits":10}`)
return
}
w.WriteHeader(404)
fmt.Fprint(w, `{}`)
}))
defer ts.Close()
_, ps := channelsCore(t, mustChannelsSrc(t))
injectTestPlatforms(t, ps, cookieishPlatformFixture)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cookieish",
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
}); err != nil {
t.Fatal(err)
}
if err := ps.SetSecret("channels", "channels:cookieish", "PASTE-TOKEN"); err != nil {
t.Fatal(err)
}
const n = 30
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(k int) {
defer wg.Done()
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"cookieish"}`,
})
if err != nil {
t.Errorf("claim #%d: %v", k, err)
return
}
j := out.JSON.(map[string]interface{})
if j["ok"] != true {
t.Errorf("claim #%d 未成功: %v", k, j)
}
}(i)
}
wg.Wait()
if got := atomic.LoadInt32(&claims); got != n {
t.Errorf("上游应收到 %d 次领取, 实得 %d", n, got)
}
}
// TestLegacyVaultPlaintextLoad 旧通路:老版本(无加密)写的明文库在新版本
// (带 box)下必须能读 —— 凭据升级不丢。落盘则会在下次写时整体重封为密文。
func TestLegacyVaultPlaintextLoad(t *testing.T) {
dir := t.TempDir()
ps := lua.NewPlugins(nil, dir)
if err := ps.SetSecret("legacy", "channels:old", "PLAIN-OLD-TOKEN"); err != nil {
t.Fatal(err)
}
ps.SetSecretBox(mustBox(t, filepath.Join(dir, "runtime.json")))
v, ok, err := ps.GetSecret("legacy", "channels:old")
if err != nil || !ok || v != "PLAIN-OLD-TOKEN" {
t.Fatalf("旧明文库读回失败: v=%q ok=%v err=%v", v, ok, err)
}
// 任意一次写触发整体重封
if err := ps.SetSecret("legacy", "k2", "NEW"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "legacy.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") {
t.Fatalf("重封后旧明文仍在盘上:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("重封后应含密文:\n%s", raw)
}
}
// TestLegacySourceSealedConfig 旧通路:config.yaml 里明文 api_key 的旧配置,
// 新版本加载后运行期拿到解密值、落盘被 seal(SealIfNeeded 语义)。
func TestLegacySourceSealedConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
yaml := "listen: 127.0.0.1:0\n" +
"gateway_keys:\n - sk-legacy-0001\n" +
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
"sources:\n" +
" - name: legacy-src\n" +
" base_url: https://legacy.example.com/v1\n" +
" adapter: openai\n" +
" api_key: PLAIN-LEGACY-KEY\n" +
" max_concurrent: 2\n" +
" models:\n" +
" - id: m1\n" +
" kind: chat\n" +
" priority: 10\n"
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
c, err := NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
got := c.Sources()
if len(got) != 1 || got[0].APIKey != "PLAIN-LEGACY-KEY" {
t.Fatalf("运行期 api_key 应解密: %#v", got)
}
raw, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") {
t.Fatalf("旧明文 api_key 升级后仍明文落盘:\n%s", raw)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw)
}
mk, err := os.ReadFile(filepath.Join(dir, "master.key"))
if err != nil || len(mk) != 64 {
t.Fatalf("master.key 应 64 hex, len=%d err=%v", len(mk), err)
}
}

View File

@ -1,111 +0,0 @@
package core
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"llmsproxy/internal/lua"
)
// Trae 签到是最容易被"看起来对"骗过的地方:请求能发出去、上游可能也返回 200,
// 但头不对就只是一个失败/风控响应。参考实现明确记录:
//
// ① 授权方案是 Cloud-IDE-JWT,**不是** Bearer(用错前缀是 401 级失败);
// ② 简化头(只有 Authorization + 几个 Ug 头)**实测签到失败**,
// 真正成功的是约 20 个客户端头;
// ③ X-Device-Id 不是凭据里的 hex32,而是**基于 user_id 确定性派生的 15 位数字**
// (每账号天然不同设备,所以不再需要 9074 时换设备号重试)。
//
// 本测试按参考逐条锁定这些事实,并把派生的 device_id 钉死到与参考一致的值。
func TestTraeCheckinHeaders(t *testing.T) {
var got map[string]string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = map[string]string{}
for k := range r.Header {
got[k] = r.Header.Get(k)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"code":0}`))
}))
defer ts.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 把签到端点指到假上游(不覆盖 checkin.auth,验证声明真的在起作用)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"checkin": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// uid 必须是真值:设备身份由它派生。用参考实现算出的期望值(见 tools/trae_derive_expected.mjs)
const uid = "12345"
// credential 传**字符串**(凭据是一段 JSON 文本),不是对象 —— complete_login
// 会原样存进保险库,取用时 credentialOf 再解出 access_token。
cred, _ := json.Marshal(map[string]interface{}{
"access_token": "ACCESS-1", "uid": uid, "device_id": "deadbeef",
})
body, _ := json.Marshal(map[string]string{
"platform": "trae", "credential": string(cred), "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"trae"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
if j, _ := out.JSON.(map[string]interface{}); j != nil && j["error"] != nil {
t.Fatalf("claim 失败: %v(完整响应 %v)", j["error"], j)
}
t.Run("行1 授权方案是 Cloud-IDE-JWT", func(t *testing.T) {
if v := got["Authorization"]; v != "Cloud-IDE-JWT ACCESS-1" {
t.Fatalf("Authorization 必须是 Cloud-IDE-JWT <token>(Bearer 会 401),实得 %q", v)
}
})
t.Run("行2 设备身份是按 user_id 派生的 15 位数字", func(t *testing.T) {
// 期望值来自参考实现(Node + node:crypto)的派生,不是本实现自己算的
if v := got["X-Device-Id"]; v != "707304999019651" {
t.Fatalf("X-Device-Id 应为按 uid 派生的 15 位数字 707304999019651,实得 %q", v)
}
})
t.Run("行3 客户端身份头齐全", func(t *testing.T) {
for k, want := range map[string]string{
"X-Market-Client-Id": "VSCode 1.107.1",
"X-User-Region": "CN",
"Package-Type": "stable_cn",
"X-Lgw-Req-Sdk-Type": "3",
"X-Lscbd-Platform": "windows",
"App-Version": "0.1.52",
} {
if v := got[k]; v != want {
t.Errorf("头 %s = %q,期望 %q", k, v, want)
}
}
if got["X-Market-User-Id"] == "" || len(got["X-Market-User-Id"]) != 36 {
t.Errorf("X-Market-User-Id 应是 UUID v4 形状,实得 %q", got["X-Market-User-Id"])
}
if len(got["Vscode-Sessionid"]) != 64 {
t.Errorf("Vscode-Sessionid 应是 64 位 hex,实得 %q", got["Vscode-Sessionid"])
}
if got["X-Tt-Trace-Id"] == "" || got["X-Request-Id"] == "" {
t.Error("每次请求都应有独立的 X-Tt-Trace-Id / X-Request-Id")
}
})
}

View File

@ -1,63 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"os"
"testing"
"llmsproxy/internal/lua"
)
// 手工移植的确定性派生必须与参考**逐字节一致**,否则签名的设备身份是错的
// 且不会报错(上游只会返回风控/失败)。所以拿参考实现(Node + node:crypto)
// 生成期望值,再让插件的 Lua 实现算同一组输入做差分。
//
// 期望值由 tools/trae_derive_expected.json 提供(用参考 seededStream 生成)。
func TestTraeDeriveMatchesReference(t *testing.T) {
raw, err := os.ReadFile("trae_derive_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
U string `json:"u"`
Devid string `json:"devid"`
Market string `json:"market"`
Sess string `json:"sess"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
for _, tc := range cases {
got, err := ps.Call("channels", "__trae_derive_probe", map[string]interface{}{"uid": tc.U})
if err != nil {
t.Fatalf("probe %q: %v", tc.U, err)
}
wrapped, _ := got.(map[string]interface{})
m, _ := wrapped["json"].(map[string]interface{})
if m == nil {
t.Fatalf("probe %q 无 json: %#v", tc.U, got)
}
if fmt.Sprint(m["devid"]) != tc.Devid {
t.Errorf("devid(%q) = %v,参考 %s", tc.U, m["devid"], tc.Devid)
}
if fmt.Sprint(m["market"]) != tc.Market {
t.Errorf("market(%q) = %v,参考 %s", tc.U, m["market"], tc.Market)
}
if fmt.Sprint(m["sess"]) != tc.Sess {
t.Errorf("sess(%q) = %v,参考 %s", tc.U, m["sess"], tc.Sess)
}
}
_ = lua.HandlerRequest{}
}

View File

@ -1,258 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// traeHarness:TRAE callback 流的测试台。假上游模拟 ExchangeToken;
// 回调用 InvokeHandler 直打 oauth_callback(Query 表 = 平台重定向回来的参数)。
type traeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
exchHit int32
exchOK int32 // 1=成功 0=失败(atomic)
}
func newTraeHarness(t *testing.T) *traeHarness {
t.Helper()
h := &traeHarness{t: t, exchOK: 1}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cloudide/api/v3/trae/oauth/ExchangeToken" {
w.WriteHeader(404)
return
}
n := atomic.AddInt32(&h.exchHit, 1)
w.Header().Set("Content-Type", "application/json")
if atomic.LoadInt32(&h.exchOK) == 0 {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"exchange down"}`)
return
}
// 校验请求体形状(对齐参考 exchangeTraeCallback 的请求构造)
body, _ := io.ReadAll(r.Body)
for _, want := range []string{`"ClientID"`, `"RefreshToken":"RT-FROM-CALLBACK"`, `"ClientSecret":"-"`} {
if !strings.Contains(string(body), want) {
t.Errorf("ExchangeToken 请求体缺 %s: %s", want, body)
}
}
fmt.Fprintf(w, `{"Token":"ACCESS-%d","RefreshToken":"RT-NEW-%d"}`, n, n)
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
// ExchangeToken 指到假上游(set_endpoints 既是运维能力也是测试缝)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"callback": map[string]interface{}{
"exchange_url": h.ts.URL + "/cloudide/api/v3/trae/oauth/ExchangeToken",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
// beginAndCallback 发起登录(拿 nonce)后直接打 oauth_callback。
// cbQuery 是回调的 query(不含 state,内部补);值即平台重定向回来的原始参数。
func (h *traeHarness) beginAndCallback(cbQuery map[string]string) (int, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
h.t.Fatal(err)
}
j, ok := out.JSON.(map[string]interface{})
if !ok {
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
}
sess := j["session"].(map[string]interface{})
nonce := sess["nonce"].(string)
q := map[string]string{"state": nonce}
for k, v := range cbQuery {
q[k] = v
}
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
Method: "GET", Query: q,
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}
// TestTraeCallbackBehavior 是 TRAE callback 流的行为对齐测试。
//
// 决策表逐行搬自参考实现 parseTraeCallbackDetailed + exchangeTraeCallback
// (trae-oauth.ts)。断言的是行为(凭据来源 / 错误文案 / 上游是否被调),不是实现细节。
func TestTraeCallbackBehavior(t *testing.T) {
h := newTraeHarness(t)
t.Run("行1 refreshToken直传_ExchangeToken换新", func(t *testing.T) {
atomic.StoreInt32(&h.exchOK, 1)
st, body := h.beginAndCallback(map[string]string{
"refreshToken": "RT-FROM-CALLBACK",
"userInfo": `{"UserID":"u1","ScreenName":"张三","TenantID":"t1"}`,
})
if st != 200 {
t.Fatalf("应 200: %d %s", st, body)
}
// 凭据必须是 ExchangeToken 的新 Token,不是回调原值。
// 凭据是 JSON(Trae 需 device_id/uid 供签到派生设备身份),故解出 access_token。
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
var cred map[string]interface{}
if err := json.Unmarshal([]byte(v), &cred); err != nil {
t.Fatalf("凭据应为 JSON(含 device_id/uid): %v (%q)", err, v)
}
if at, _ := cred["access_token"].(string); !strings.HasPrefix(at, "ACCESS-") {
t.Fatalf("凭据应是 ExchangeToken 的新 access, 实得 %q", at)
}
// uid 来自 userInfo.UserID —— 签到设备身份的派生种子,丢了就无法签到
if cred["uid"] != "u1" {
t.Fatalf("凭据应保留 uid=u1(签到设备身份的种子), 实得 %v", cred["uid"])
}
if cred["enterprise_id"] != "t1" {
t.Fatalf("凭据应保留 TenantID, 实得 %v", cred["enterprise_id"])
}
// 设备身份来自登录会话(授权 URL 里下发的那对 hex32)
if d, _ := cred["device_id"].(string); len(d) != 32 {
t.Fatalf("凭据应带 32 位 device_id(签到轮换用), 实得 %q", d)
}
})
t.Run("行2 无refreshToken_userJwtToken兜底_不打ExchangeToken", func(t *testing.T) {
before := atomic.LoadInt32(&h.exchHit)
st, body := h.beginAndCallback(map[string]string{
"userJwt": `{"Token":"JWT-TOKEN-1","RefreshToken":""}`,
})
if st != 200 {
t.Fatalf("应 200: %d %s", st, body)
}
v, _, _ := h.ps.GetSecret("channels", "channels:trae")
var cred2 map[string]interface{}
if err := json.Unmarshal([]byte(v), &cred2); err != nil {
t.Fatalf("凭据应为 JSON: %v (%q)", err, v)
}
if cred2["access_token"] != "JWT-TOKEN-1" {
t.Fatalf("兜底凭据应是 userJwt.Token, 实得 %v", cred2["access_token"])
}
if atomic.LoadInt32(&h.exchHit) != before {
t.Fatal("无 refreshToken 不应调 ExchangeToken(参考实现分支2)")
}
})
t.Run("行3 只有code_PKCE流_明确报暂不支持", func(t *testing.T) {
st, body := h.beginAndCallback(map[string]string{"code": "PKCE-CODE-1"})
if st != 400 {
t.Fatalf("PKCE 流应 400: %d %s", st, body)
}
if !strings.Contains(body, "PKCE") {
t.Fatalf("错误应明确指向 PKCE 流, 实得 %s", body)
}
})
t.Run("行4 空回调_报缺失", func(t *testing.T) {
st, body := h.beginAndCallback(map[string]string{})
if st != 400 {
t.Fatalf("空回调应 400: %d %s", st, body)
}
if !strings.Contains(body, "refreshToken") {
t.Fatalf("错误应指出缺什么, 实得 %s", body)
}
})
t.Run("行5 ExchangeToken失败_透传阶段", func(t *testing.T) {
atomic.StoreInt32(&h.exchOK, 0)
defer atomic.StoreInt32(&h.exchOK, 1)
st, body := h.beginAndCallback(map[string]string{"refreshToken": "RT-FROM-CALLBACK"})
if st != 400 {
t.Fatalf("exchange 失败应 400: %d %s", st, body)
}
if !strings.Contains(body, "ExchangeToken") {
t.Fatalf("错误应指明 ExchangeToken 阶段, 实得 %s", body)
}
})
}
// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
//
// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
// 凭据落到与占位账号无关的 `channels:<plat>` 键,池账号永远 has_credential=false。
// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
func TestTraeCallbackPoolPath(t *testing.T) {
h := newTraeHarness(t)
atomic.StoreInt32(&h.exchOK, 1)
// UI 流程:先建占位账号,再带 account_id 发起登录
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid),
})
if err != nil {
t.Fatal(err)
}
nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
st, body := h.invokeCallback(map[string]string{
"state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
})
if st != 200 {
t.Fatalf("回调应 200: %d %s", st, body)
}
// ① 凭据必须落在池账号的 ref
v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
if !ok || v == "" {
t.Fatalf("callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
}
// ② account_list 必须报 has_credential=true
lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
t.Fatal(err)
}
for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
a := raw.(map[string]interface{})
if a["id"] == aid && a["has_credential"] != true {
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
}
}
}
// invokeCallback 直打 oauth_callback(Query 已含 state)。
func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
Method: "GET", Query: q,
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}

View File

@ -1,100 +0,0 @@
package core
import (
"testing"
)
// 通用件:TTL 缓存 + 串行锁(照参考 ttl-cache.ts / serial-queue.ts)。
//
// 抽它们出来的理由:本插件里已经有多处**各自手写**的"带冷却的缓存"与"串行"
// 逻辑(远端目录闸门、余额 60s 缓存、登录轮询…)。手写版本各写各的,
// 边界条件容易漏 —— 实测就漏了一个(见下面 ③)。
func TestTtlCacheSemantics(t *testing.T) {
_, ps := poolCore(t)
if _, err := ps.Call("channels", "__ttl_reset_probe", nil); err != nil {
t.Fatalf("重置: %v", err)
}
// ① 首次加载后命中缓存:loader 只该被调一次
out, err := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
if err != nil {
t.Fatalf("探针: %v", err)
}
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
if j["value"] != "v1" {
t.Errorf("首次应加载出 v1,实得 %v", j["value"])
}
if n, _ := j["loads"].(float64); n != 1 {
t.Errorf("首次后 loads 应为 1,实得 %v", n)
}
// 再取一次:命中缓存,loads 不变
out2, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j2["loads"].(float64); n != 1 {
t.Errorf("命中缓存时不该再加载,loads 实得 %v", n)
}
// ② 失效后必须能重新加载(包括同一秒内 —— 见 ③)
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
out3, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 60,
})
j3, _ := out3.(map[string]interface{})["json"].(map[string]interface{})
// ⚠️ 这是实测踩到的坑:只清缓存、不清"在飞标记"时,同一秒内的重载会被
// 去重逻辑挡住,返回 nil —— 表现为"刚清了缓存却拿不到值"。
if j3["value"] == nil {
t.Fatalf("失效后立刻重载必须拿到值(清缓存时也要清在飞标记),实得 nil")
}
if n, _ := j3["loads"].(float64); n != 2 {
t.Errorf("失效后应重新加载(loads=2),实得 %v", n)
}
// ③ TTL=0 视为立即过期:不该命中
if _, err := ps.Call("channels", "__ttl_invalidate_probe", map[string]interface{}{
"ns": "t", "key": "k",
}); err != nil {
t.Fatalf("失效: %v", err)
}
_, _ = ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
out5, _ := ps.Call("channels", "__ttl_probe", map[string]interface{}{
"ns": "t", "key": "k", "ttl": 0,
})
j5, _ := out5.(map[string]interface{})["json"].(map[string]interface{})
if n, _ := j5["loads"].(float64); n < 3 {
t.Errorf("ttl=0 不该命中缓存(每次都要重载),loads 实得 %v", n)
}
// ④ 串行锁:抢到 → 第二次抢不到 → 释放后能再抢
a, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
ja, _ := a.(map[string]interface{})["json"].(map[string]interface{})
if ja["acquired"] != true {
t.Errorf("首次应抢到,实得 %v", ja["acquired"])
}
b, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jb, _ := b.(map[string]interface{})["json"].(map[string]interface{})
if jb["acquired"] != false {
t.Errorf("持有期间第二次不该抢到(这就是串行的意义),实得 %v", jb["acquired"])
}
if _, err := ps.Call("channels", "__serial_release_probe", map[string]interface{}{
"ns": "s"}); err != nil {
t.Fatalf("释放: %v", err)
}
c, _ := ps.Call("channels", "__serial_probe", map[string]interface{}{
"ns": "s", "ttl": 60})
jc, _ := c.(map[string]interface{})["json"].(map[string]interface{})
if jc["acquired"] != true {
t.Errorf("释放后应能再抢到,实得 %v", jc["acquired"])
}
}

View File

@ -1,59 +0,0 @@
package core
import (
"strings"
"testing"
)
// VNC 一直白屏的真因(2026-10-14 定位)。
//
// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium
// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出:
//
// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002
// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye.
//
// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报
// 「已就绪」,把白屏谎报成成功。
//
// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠
// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem
// 能复现,其余加固项无影响。
//
// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。
func TestBrowserStackUsesInProcessGPU(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来
if !strings.Contains(b, "--in-process-gpu") {
t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " +
"SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" +
"浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)")
}
// ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读,
// chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声
for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} {
if !strings.Contains(b, k) {
t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k)
}
}
// ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。
// 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏,
// 还会以为是自己操作的问题。谎报比直接报错更坏。
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功")
}
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」")
}
// ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify)
idx := strings.Index(b, "浏览器启动后即退出")
if idx < 0 {
t.Fatal("应有「浏览器启动后即退出」的失败分支")
}
if !strings.Contains(b[idx:idx+600], "reapBrowser()") {
t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)")
}
}

View File

@ -1,70 +0,0 @@
package core
import (
"strings"
"testing"
)
// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。
//
// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个
// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是
// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸:
//
// attempt to call global 'reapLoopback' (a nil value)
//
// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500,
// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。
// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。
func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// ① 必须存在对这三个助手的前向声明
if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") {
t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " +
"`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" +
"正常、第二次 500,且浏览器栈不会被回收)")
}
// ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function`
// —— 那会遮蔽前向声明,效果与没声明完全相同。
for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} {
if strings.Contains(b, "local function "+fn+"(") {
t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+
"再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn)
}
if !strings.Contains(b, "\n"+fn+" = function(") {
t.Errorf("找不到 %s 的赋值式定义", fn)
}
}
// ③ 声明必须**先于**使用(行号比较):这是前向引用的核心
decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser")
use := strings.Index(b, "\n reapLoopback(id)\n")
if decl < 0 || use < 0 {
t.Fatalf("找不到声明(%d)或使用(%d)", decl, use)
}
if decl > use {
t.Error("前向声明必须在**使用之前** —— 否则 nil 调用")
}
}
// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。
func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// CDP 真应答(端口在听 ≠ 服务可用)
if !strings.Contains(b, "browserUtil.cdpAlive") {
t.Error("必须用 CDP 真应答判活,不能只看端口")
}
// 进程真存活
if !strings.Contains(b, "/proc/") {
t.Error("必须检查 chromium 进程仍存活(/proc/<pid>)")
}
// 失败要把日志真因带出来(而不是一句"启动失败")
if !strings.Contains(b, "browserUtil.readTail") {
t.Error("启动失败必须带出日志真因")
}
}

View File

@ -1,153 +0,0 @@
package core
import (
"net/url"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
//
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
func TestChannelsVncStackEndToEnd(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
// 依赖预检:缺件则跳过(并让日志说明)
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if err != nil {
t.Fatalf("deps_status: %v", err)
}
if depOut.JSON.(map[string]interface{})["ok"] != true {
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
}
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
injectTestPlatforms(t, ps, `[{
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
"models": ["v-model-1"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
m := out.JSON.(map[string]interface{})
if e, _ := m["browser_error"].(string); e != "" {
t.Fatalf("浏览器栈未能启动: %s", e)
}
view, _ := m["view_url"].(string)
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
}
sess := m["session"].(map[string]interface{})
if sess["cdp_port"] == nil {
t.Fatal("会话应带 CDP 端口(取凭据用)")
}
// ★ noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
// 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
// 真实缺陷:传了站点根路径 ⇒ 路径被重复拼接,WebSocket 永远连不上,
// 页面看似白屏:
// .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
// 本判据必须解析**产品自己给出的 URL**:我曾在验证时手拼 URL 并省掉
// path 参数(默认值恰好正确)⇒ 否定了真实缺陷。
u, perr := url.Parse(view)
if perr != nil {
t.Fatalf("view_url 不可解析: %v", perr)
}
q := u.Query()
wspath := q.Get("path")
if wspath == "" {
t.Fatal("view_url 必须显式声明 path=websockify:依赖 noVNC 隐式默认值" +
"会在 URL 形状变化时静默破裂")
}
if strings.HasPrefix(wspath, "/") {
t.Fatalf("path 必须是相对名(noVNC 相对 vnc.html 目录解析),"+
"以 / 开头会被当相对路径而重复拼接,实得 %q", wspath)
}
resolved := u.ResolveReference(&url.URL{Path: wspath})
pages := strings.TrimSuffix(u.Path, "/vnc.html")
if resolved.Path != pages+"/websockify" {
t.Fatalf("path 解析后应为 %q(同级 websockify),实得 %q —— 重复拼接即白屏根因",
pages+"/websockify", resolved.Path)
}
t.Logf("VNC 栈已起: %s → WebSocket %s (CDP %v)", view, resolved.Path, sess["cdp_port"])
// 收尾:回收浏览器栈(测试不留孤儿进程)
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
}); err != nil {
t.Fatalf("stop_browser: %v", err)
}
}
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
//
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
// (实测一次跑测试留下 4 个 orphan Xvfb)。
//
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
func TestChannelsVncNoOrphanOnFailure(t *testing.T) {
c := kernelTestCore(t)
ps := c.Plugins()
src, err := lua.ReadBundledPlugin("channels")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if depOut.JSON.(map[string]interface{})["ok"] != true {
t.Skip("跳过:缺浏览器栈依赖")
}
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
injectTestPlatforms(t, ps, `[{
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
}
// 起来后立刻回收
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
t.Fatal(err)
}
// 回收后状态必须清零
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
if st.JSON.(map[string]interface{})["running"] != false {
t.Fatal("stop_browser 后 running 应为 false")
}
}

View File

@ -1,203 +0,0 @@
package core
import (
"os"
"strconv"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// 显示号泄漏是「测试单独跑绿、全量跑红」的根源,值得一条判据钉住它。
//
// 现场(2026-10-13):/tmp/.X<n>-lock 在 Xvfb 被 SIGKILL 后会留下(插件收尾
// 就是 `kill -9`)。而旧选号逻辑是「锁/socket 存在就当被占」→ 每个被强杀的
// 显示器**永久烧掉一个号**。反复跑测试/反复登录后 91 起的整段号被占满
// (实测 41/41),于是 pickFree 扫不到空位 → `return start`(91) → Xvfb 报
// “Server is already active for display 91”。
// 表现为 TestChannelsVncStackEndToEnd 单独 1.07s 通过、全量跑 15s 超时失败。
//
// 判据是**行为**,分三步(注意:插件占用期间锁存在是**正确**的 —— Xvfb 自己
// 会写锁;错的是「用完之后不清理」和「被别人的陈旧锁挡死」):
// 1. 陈旧锁不得挡住选号(记录 PID 已死)→ 能正常起来,且不报 already active;
// 2. 空闲号被选中后,锁里应是**真实存活的** PID(即插件真占用了它);
// 3. 收栈之后,该号的锁与 socket 必须被清掉(这才是泄漏的根治)。
func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
const ghost = "999999" // 必然不存在的 PID
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
//
// ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。
// 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里,
// 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300
// (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的
// 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。
base := 300
if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
base = n
}
}
ghosts := []int{base, base + 1, base + 2}
for _, n := range ghosts {
l := "/tmp/.X" + strconv.Itoa(n) + "-lock"
if _, err := os.Stat(l); err == nil {
t.Skipf("前置条件不满足:%s 已存在(先清理测试环境再跑)", l)
}
if err := os.WriteFile(l, []byte(ghost+"\n"), 0o644); err != nil {
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
}
defer os.Remove(l)
}
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 12 个真实平台里已无 vnc 流,故注入一个夹具(夹具不写进生产目录)。
injectTestPlatforms(t, ps, `[{
"id": "xdispfx", "name": "显示器夹具", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18099/login",
"base_url": "https://api.xdispfx.example/v1", "adapter": "openai",
"models": ["x-model-1"]
}]`)
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"xdispfx"}`, Role: "admin",
})
if err != nil {
t.Fatalf("start_login: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
if j == nil {
t.Fatalf("无 JSON 响应 (status=%d)", out.Status)
}
msg, _ := j["browser_error"].(string)
if strings.Contains(msg, "already active for display") {
t.Fatalf("陈旧锁把选号挡死了(应回收):%s", msg)
}
if msg != "" {
// 缺浏览器依赖起不来是允许的 —— 但陈旧锁仍必须被回收(不占号)。
if _, err := os.Stat(lock); err == nil {
t.Errorf("陈旧锁未被回收: %s(记录 PID %s 已不存在)", lock, ghost)
}
t.Skipf("浏览器栈未启动(与选号无关): %s", msg)
}
// ② 插件确实占用了某个显示器,且锁里是**活着的** PID(不是那个幽灵)。
st, err := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
Method: "GET", Role: "admin",
})
if err != nil {
t.Fatalf("browser_status: %v", err)
}
sess, _ := st.JSON.(map[string]interface{})["session"].(map[string]interface{})
used := 0
if sess != nil {
if f, ok := sess["display"].(float64); ok {
used = int(f)
}
}
if used == 0 {
t.Fatalf("会话未报告 display: %v", st.JSON)
}
// 关键断言:选中的必须是那 3 个陈旧号之一 —— 证明陈旧锁被**回收复用**了。
// 只跳过不复用的实现(变异)会选 94,从而被抓到。
reclaimed := false
for _, n := range ghosts {
if used == n {
reclaimed = true
}
}
if !reclaimed {
t.Errorf("选中的 :%d 不是陈旧号 %v —— 陈旧锁没被回收复用(号段会被逐个烧掉,最终饿死)", used, ghosts)
}
if raw, err := os.ReadFile("/tmp/.X" + strconv.Itoa(used) + "-lock"); err == nil {
pid := strings.TrimSpace(string(raw))
if pid == ghost {
t.Errorf("选中的显示器 :%d 锁里仍是幽灵 PID(陈旧锁没被换掉)", used)
} else if pid != "" {
if _, err := os.Stat("/proc/" + pid); err != nil {
t.Errorf("选中的显示器 :%d 锁里 PID %s 不存在(记录与事实不符)", used, pid)
}
}
} else {
t.Logf(":%d 未生成锁文件(部分 Xvfb 行为,可接受)", used)
}
// ③ 收栈后必须不留锁/socket —— 这条直接钉住「用 kill -9 收尾却不清理」的泄漏。
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
}); err != nil {
t.Fatalf("stop_browser: %v", err)
}
dl := "/tmp/.X" + strconv.Itoa(used) + "-lock"
ds := "/tmp/.X11-unix/X" + strconv.Itoa(used)
if _, err := os.Stat(dl); err == nil {
t.Errorf("收栈后锁未清理: %s(下次会把这个号误判为被占 → 号被永久烧掉)", dl)
_ = os.Remove(dl)
}
if _, err := os.Stat(ds); err == nil {
t.Errorf("收栈后 socket 未清理: %s", ds)
_ = os.Remove(ds)
}
_ = sock
}
// 反向判据:**活着的**占用不得被回收(否则会把别人正在用的显示器抢走)。
func TestXDisplaySelectionKeepsLiveLocks(t *testing.T) {
const disp = 92
lock := "/tmp/.X" + strconv.Itoa(disp) + "-lock"
if _, err := os.Stat(lock); err == nil {
t.Skipf("前置条件不满足:%s 已存在", lock)
}
// 用一个**真实存活**的 PID(本进程)写锁。
if err := os.WriteFile(lock, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
t.Skipf("无法在 /tmp 造锁(跳过): %v", err)
}
defer os.Remove(lock)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
injectTestPlatforms(t, ps, `[{
"id": "xdispfx2", "name": "显示器夹具2", "flow": "vnc", "needs_browser": true,
"login_url": "http://127.0.0.1:18097/login",
"base_url": "https://api.xdispfx2.example/v1", "adapter": "openai",
"models": ["x-model-2"]
}]`)
defer func() {
_, _ = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
Method: "POST", Role: "admin",
})
}()
if _, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"xdispfx2"}`, Role: "admin",
}); err != nil {
t.Fatalf("start_login: %v", err)
}
// 记录的是活 PID → 该锁必须原样保留(不得抢占别人正在用的显示器)。
raw, err := os.ReadFile(lock)
if err != nil {
t.Fatalf("活锁被误回收: %s(记录的是真实存活 PID,不得回收)", lock)
}
if got := strings.TrimSpace(string(raw)); got != strconv.Itoa(os.Getpid()) {
t.Errorf("活锁内容被改动: %q(应保持原样)", got)
}
}

View File

@ -1,188 +0,0 @@
package core
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// zcodeHarness:ZCode 两段式服务端中介授权流的测试台。
//
// 参考语义(zcode-login.ts 逐行):
//
// ① POST /api/v1/oauth/cli/init → {flow_id, authorize_url, poll_interval_sec}
// Authorization 是**本地造的会话密钥**(不是用户凭据)
// ② 用户在浏览器打开 authorize_url
// ③ GET /api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
// 判据:4xx(除 408/429)= 终态失败;5xx/网络错误 = 继续等;
// code!=0 或 data 缺失 = pending;status=pending = 继续
//
// 此前标 flow="manual"(理由"要解 captcha"),但该授权流**完全不涉及 captcha**
// —— captcha 属于另一条已废弃的旁路(读取本机官方客户端数据)。
type zcodeHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
polls int32
readyAt int32
gotInit map[string]interface{}
gotAuth string
}
func newZcodeHarness(t *testing.T) *zcodeHarness {
t.Helper()
h := &zcodeHarness{t: t, readyAt: 2}
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == "POST" && r.URL.Path == "/api/v1/oauth/cli/init":
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &h.gotInit)
h.gotAuth = r.Header.Get("Authorization")
fmt.Fprint(w, `{"code":0,"data":{"flow_id":"FLOW-1","poll_token":"PT",
"authorize_url":"https://bigmodel.cn/login?appId=zcode&redirect=x",
"expires_at":9999999999,"poll_interval_sec":2}}`)
case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/api/v1/oauth/cli/poll/"):
if !strings.HasSuffix(r.URL.Path, "FLOW-1") {
fmt.Fprint(w, `{"code":1,"msg":"unknown flow"}`)
return
}
n := atomic.AddInt32(&h.polls, 1)
if n < atomic.LoadInt32(&h.readyAt) {
fmt.Fprint(w, `{"code":0,"data":{"status":"pending"}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"status":"ready",
"token":"ZCODE-JWT-1",
"user":{"user_id":"u7","name":"zcode-dev"},
"bigmodel":{"access_token":"BM-AT-1","refresh_token":"BM-RT-1"}}}`)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "zcode",
"initpoll": map[string]interface{}{
"init_url": h.ts.URL + "/api/v1/oauth/cli/init",
"poll_url": h.ts.URL + "/api/v1/oauth/cli/poll/{flow_id}",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
func (h *zcodeHarness) begin() (map[string]interface{}, string) {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
j := out.JSON.(map[string]interface{})
return j, j["session"].(map[string]interface{})["id"].(string)
}
func (h *zcodeHarness) pollOnce(sid string) map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid}, Role: "admin",
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
// TestZcodeInitPollBehavior 逐行锁定参考的 ZCode 授权流语义。
func TestZcodeInitPollBehavior(t *testing.T) {
h := newZcodeHarness(t)
t.Run("行1 init带本地会话密钥与provider", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
j, _ := h.begin()
if h.gotInit["provider"] != "bigmodel" {
t.Fatalf("init body 应带 provider=bigmodel, 实得 %v", h.gotInit)
}
// Authorization 是我们自己生成的会话密钥(Bearer 后非空),不是用户凭据
if !strings.HasPrefix(h.gotAuth, "Bearer ") || len(h.gotAuth) < 20 {
t.Fatalf("init 应带本地会话密钥作 Bearer, 实得 %q", h.gotAuth)
}
if u, _ := j["url"].(string); !strings.Contains(u, "bigmodel.cn") {
t.Fatalf("应给出 authorize_url, 实得 %q", u)
}
})
t.Run("行2 pending状态保持pending", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 99)
_, sid := h.begin()
for i := 0; i < 3; i++ {
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("status=pending 应保持 pending, 实得 %v", st["state"])
}
}
})
t.Run("行3 ready即完成_凭据含zcodeJwt与第三方token", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 2)
_, sid := h.begin()
if st := h.pollOnce(sid); st["state"] != "pending" {
t.Fatalf("首拍应 pending: %v", st)
}
st := h.pollOnce(sid)
if st["state"] != "completed" {
t.Fatalf("ready 应 completed, 实得 %v", st)
}
v, ok, _ := h.ps.GetSecret("channels", "channels:zcode")
if !ok {
t.Fatal("凭据未落库")
}
for _, want := range []string{"ZCODE-JWT-1", "BM-AT-1", "zcode-dev"} {
if !strings.Contains(v, want) {
t.Fatalf("凭据应含 %q, 实得 %s", want, v)
}
}
})
t.Run("行4 池路径_凭据落账号ref", func(t *testing.T) {
atomic.StoreInt32(&h.polls, 0)
atomic.StoreInt32(&h.readyAt, 1)
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"zcode"}`, Role: "admin",
})
if err != nil {
t.Fatal(err)
}
aid := add.JSON.(map[string]interface{})["account_id"].(string)
out, _ := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: fmt.Sprintf(`{"platform":"zcode","account_id":%q}`, aid), Role: "admin",
})
sid := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["id"].(string)
if st := h.pollOnce(sid); st["state"] != "completed" {
t.Fatalf("池路径应完成: %v", st)
}
if _, ok, _ := h.ps.GetSecret("channels", "channels:zcode:"+aid); !ok {
t.Fatalf("池路径凭据未落到 channels:zcode:%s", aid)
}
})
}

View File

@ -1,97 +0,0 @@
package core
import (
"encoding/json"
"os"
"strings"
"testing"
)
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
//
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
//
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
//
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
// 以及参与签名的头列表(顺序与内容)。
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
raw, err := os.ReadFile("codearts_sig_expected.json")
if err != nil {
t.Skipf("缺差分数据(跳过): %v", err)
}
var cases []struct {
Method string `json:"method"`
URL string `json:"url"`
Body string `json:"body"`
AK string `json:"ak"`
SK string `json:"sk"`
ST string `json:"st"`
Canonical string `json:"canonical"`
StringToSign string `json:"string_to_sign"`
Signature string `json:"signature"`
SignedHeaders string `json:"signed_headers"`
}
if err := json.Unmarshal(raw, &cases); err != nil {
t.Fatal(err)
}
if len(cases) == 0 {
t.Fatal("差分数据为空")
}
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
for i, tc := range cases {
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
"method": tc.Method, "url": tc.URL, "body": tc.Body,
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
})
if err != nil {
t.Fatalf("case %d probe: %v", i, err)
}
w, _ := got.(map[string]interface{})
m, _ := w["json"].(map[string]interface{})
if m == nil {
t.Fatalf("case %d 无 json: %#v", i, got)
}
if m["error"] != nil {
t.Fatalf("case %d 探针报错: %v", i, m["error"])
}
if s, _ := m["canonical"].(string); s != tc.Canonical {
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
}
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
}
if s, _ := m["signature"].(string); s != tc.Signature {
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
}
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
}
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
if s, _ := m["authorization"].(string); len(s) == 0 ||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
}
// 签名后追加的头绝不能进 canonical
if s, _ := m["canonical"].(string); len(s) > 0 {
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
if strings.Contains(s, bad) {
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
}
}
}
}
}

View File

@ -11,17 +11,15 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"llmsproxy/internal/billing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/provider"
"llmsproxy/internal/scheduler"
"modelrouter/internal/config"
"modelrouter/internal/lua"
"modelrouter/internal/provider"
"modelrouter/internal/scheduler"
)
// Core owns the running configuration and adapters. mu guards every read and
@ -97,9 +95,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
for name, aud := range cfg.PluginVisibility {
c.plugins.SetPluginVisibility(name, aud)
}
if err := c.plugins.SeedBundled(); err != nil {
log.Printf("[core] seed bundled plugins: %v", err)
}
if err := c.plugins.LoadDir(); err != nil {
log.Printf("[core] plugin dir: %v", err)
}
@ -111,7 +106,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
p["name"], p["version"], p["hooks"], p["ui"])
}
c.applyBillingDSL()
c.store = config.NewStore(cfg.RuntimeFile)
// Share one box between the runtime store and config.yaml so a single
// master.key seals both files. config.Load left the config holding
@ -150,9 +144,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
if err := c.seedKeys(); err != nil {
return nil, err
}
if err := c.seedAuto(); err != nil {
return nil, err
}
if err := c.rebuildRegistry(); err != nil {
return nil, err
}
@ -195,39 +186,6 @@ func (c *Core) migrateFromRuntime() {
}
}
// seedAuto uses the existing auto rules from config.yaml, or creates an
// initial chain from legacy per-source model priority (first run only).
func (c *Core) seedAuto() error {
if len(c.cfg.Auto) > 0 {
return nil
}
type item struct {
model string
prio int
}
var flat []item
for _, s := range c.mergedSources() {
for _, m := range s.Models {
if m.Kind == "image" {
continue
}
flat = append(flat, item{m.ID, m.Priority})
}
}
sort.SliceStable(flat, func(i, j int) bool {
if flat[i].prio != flat[j].prio {
return flat[i].prio > flat[j].prio
}
return flat[i].model < flat[j].model
})
entries := make([]config.ModelScope, 0, len(flat))
for _, it := range flat {
entries = append(entries, config.ModelScope{Model: it.model})
}
c.cfg.Auto = entries
return c.cfg.Save()
}
// seedKeys ensures config.yaml has admin keys for every gateway_keys entry
// from the YAML config (once). After first save they become GWKey records
// in c.cfg.Keys and gateway_keys is no longer read for auth.
@ -877,9 +835,23 @@ func (c *Core) Reload() error {
// ---- adapter management (web UI) ----
func (c *Core) ListAdapters() []lua.APIAdapter {
c.syncHiddenRefs()
return c.vm.ListAdapters()
}
// syncHiddenRefs 把"当前被源引用的适配器"同步给 VM。被引用的 hidden=true 适配器
// 必须保持可见(源编辑器要回显当前值);没被引用的隐藏适配器从列表消失。
// 每次 ListAdapters 前调用:源集合是动态的(运行时可增删),推模型容易漏。
func (c *Core) syncHiddenRefs() {
refs := []string{}
for _, s := range c.mergedSources() {
if s.Adapter != "" {
refs = append(refs, s.Adapter)
}
}
c.vm.SetHiddenRefs(refs)
}
func (c *Core) UploadAdapter(name, code string) error {
if name == "" {
return fmt.Errorf("adapter name required")
@ -1033,63 +1005,6 @@ func (c *Core) Close() {
}
}
// applyBillingDSL compiles the configured billing profile and injects it into
// the billing plugin.
//
// Configured pricing replaces the hand-written JSON blob that used to be PUT
// through the state API. That path was used twice in production and both times
// it "succeeded" while being wrong: once without the required `prices` wrapper
// (silently wiping the accumulated totals), once with a peak-window shape the
// plugin does not read (peak traffic billed at off-peak rates, no error). A
// typed config with load-time validation turns both mistakes into startup
// errors naming the file and field.
//
// A DSL with no profile, or one that fails to compile, leaves the plugin's
// prices untouched: billing keeps running on whatever it had, and the problem
// is logged rather than becoming a startup failure — the gateway must forward
// even when its cost accounting is misconfigured.
func (c *Core) applyBillingDSL() {
ps := c.plugins
if ps == nil || ps.Count() == 0 {
return
}
// The billing plugin may simply not be installed; a DSL without it is a
// no-op, not an error.
var have bool
for _, row := range ps.List() {
if row["name"] == "billing" && row["loaded"] == true {
have = true
break
}
}
if !have {
return
}
dsl := c.cfg.BillingDSL
if dsl == nil || len(dsl.Profiles) == 0 {
return
}
if err := dsl.Validate(); err != nil {
log.Printf("[billing] config invalid, profile not applied: %v", err)
return
}
profile := dsl.Resolve(dsl.Active)
if profile == nil {
log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active)
return
}
prices, err := billing.Compile(profile, c.cfg.Sources)
if err != nil {
log.Printf("[billing] profile %q not applied: %v", profile.ID, err)
return
}
if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil {
log.Printf("[billing] applying profile %q: %v", profile.ID, err)
return
}
log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources))
}
// ValidateScopeQuotas checks every scope entry's caps before they are stored.
// A typo in a period must be rejected at write time rather than silently
// becoming a never-resetting budget — the opposite of what was typed.

View File

@ -5,7 +5,7 @@ import (
"sync"
"testing"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
// newTestConfig returns an in-memory config backed by temp files so
@ -22,7 +22,7 @@ func newTestConfig(t *testing.T) *config.Config {
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-gw-test"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}}},
},
}
}

View File

@ -1,180 +0,0 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。
func readBundledChannels(t *testing.T) (string, error) {
t.Helper()
return lua.ReadBundledPlugin("channels")
}
// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应)
// + channels 插件加载指定源码 + minimax 端点指到假上游。
// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有
// 一份,两处不会漂移。
type deviceHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
step int32
// script 持续被 HTTP handler 原子读取
script atomic.Value // []scriptStep
}
type scriptStep struct {
http int
body string
}
func newDeviceHarness(t *testing.T, src string) *deviceHarness {
t.Helper()
h := &deviceHarness{t: t}
h.script.Store([]scriptStep{})
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth2/device/code":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
case "/oauth2/token":
i := int(atomic.AddInt32(&h.step, 1)) - 1
script := h.script.Load().([]scriptStep)
w.Header().Set("Content-Type", "application/json")
if i >= len(script) {
w.WriteHeader(500)
fmt.Fprint(w, `{"error":"unexpected"}`)
return
}
if script[i].http != 0 && script[i].http != 200 {
w.WriteHeader(script[i].http)
}
fmt.Fprint(w, script[i].body)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
h.ps = c.Plugins()
if err := h.ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载: %v", err)
}
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "minimax",
"device": map[string]interface{}{
"code_url": h.ts.URL + "/oauth2/device/code",
"token_url": h.ts.URL + "/oauth2/token",
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
return h
}
// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。
func (h *deviceHarness) setScript(steps ...string) {
var out []scriptStep
for _, b := range steps {
code := 200
switch {
case strings.HasPrefix(b, "400 "):
code, b = 400, b[4:]
case strings.HasPrefix(b, "403 "):
code, b = 403, b[4:]
}
out = append(out, scriptStep{http: code, body: b})
}
h.script.Store(out)
atomic.StoreInt32(&h.step, 0)
}
func (h *deviceHarness) begin() map[string]interface{} {
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"minimax"}`,
})
if err != nil {
h.t.Fatal(err)
}
return out.JSON.(map[string]interface{})
}
func (h *deviceHarness) poll(id string) map[string]interface{} {
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": id},
})
return out.JSON.(map[string]interface{})
}
// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。
// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。
func (h *deviceHarness) runDecisionTable() bool {
ok := func(cond bool) bool { return cond }
st := func(steps ...string) map[string]interface{} {
h.setScript(steps...)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
return h.poll(sid)
}
// 行1: 200+pending → 等
if j := st(`{"status":"pending"}`); j["state"] != "pending" {
return false
}
// 行2: 200+slow_down → 等 + interval+5
h.setScript(`{"status":"slow_down"}`)
j := h.begin()
sid := j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv < 6 {
return false
}
// 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体)
if j := st(`{"status":"denied"}`); j["state"] != "failed" {
return false
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") {
return false
}
// 行4: 200+expired_token → failed,message 带过期字样
if j := st(`{"status":"expired_token"}`); j["state"] != "failed" {
return false
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") {
return false
}
// 行5: 400+authorization_pending → 等
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
return false
}
// 行6: 403+slow_down → 等 + interval+5
h.setScript(`403 {"error":"slow_down"}`)
j = h.begin()
sid = j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "pending" {
return false
} else if iv, _ := j2["interval"].(float64); iv < 6 {
return false
}
// 行7: 200+access_token → completed(需先授权 add_source)
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
j = h.begin()
sid = j["session"].(map[string]interface{})["id"].(string)
if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" {
return false
}
// 行8: 其它 error → failed 带文本
if j := st(`{"error":"server_error"}`); j["state"] != "failed" {
return false
}
return ok(true)
}

View File

@ -1,73 +0,0 @@
package core
import (
"strings"
"testing"
)
// TestDevicePollMutants 是 device-poll 执行器的变异测试。
//
// 用户要求:移植要照参考实现的行为形成测试项,且要变异验证测试真的能杀掉
// 「行为走样」的代码。每个用例把 channels 源里的一个决策分支做语义变异,
// 断言决策表**会失败**(变异体被杀)。变异体幸存 = 该分支没有覆盖。
func TestDevicePollMutants(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
// 锚点会命中 deviceOneStep 里两处同形代码(workos 分支 + minimax 分支),
// 全部替换:两个决策表(minimax + workos)都会杀,任一幸存即失败。
muts := []struct {
name string
from string
to string
}{
{
name: "slow_down 不再加 interval",
from: `sess.device_interval = (sess.device_interval or d.interval or 5) + step`,
to: `sess.device_interval = (sess.device_interval or d.interval or 5)`,
},
{
name: "denied 被当成继续等待",
from: `if resp.status == 200 and (status == "denied" or status == "access_denied") then`,
to: `if resp.status == 200 and (status == "denied_x" or status == "access_denied") then`,
},
{
name: "expired_token 不被识别",
from: `if resp.status == 200 and (status == "expired" or status == "expired_token") then`,
to: `if resp.status == 200 and (status == "expired" or status == "expired_token_x") then`,
},
{
name: "authorization_pending 被当成失败",
from: `if ecode == "authorization_pending" then return false end`,
to: `if ecode == "authorization_pending_x" then return false end`,
},
{
name: "成功分支被禁用",
from: `if resp.status == 200 and rec.access_token then`,
to: `if resp.status == 200 and rec.access_token and false then`,
},
}
for _, mu := range muts {
t.Run(mu.name, func(t *testing.T) {
if !strings.Contains(src, mu.from) {
t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from)
}
// 锚点在 deviceOneStep 的 workos/minimax 两个分支各有一份同形代码,
// 全部替换:任一分支的变异体被任一决策表杀掉才算有覆盖。
mutated := strings.ReplaceAll(src, mu.from, mu.to)
if mutated == src {
t.Fatal("变异未生效")
}
// 基准必须全过(测试自身有效),变异体必须被杀(有覆盖)
if !newDeviceHarness(t, src).runDecisionTable() {
t.Fatal("基准决策表未全过 —— 测试自身失效")
}
if newDeviceHarness(t, mutated).runDecisionTable() {
t.Fatalf("变异体幸存(%s)—— 该分支无覆盖", mu.name)
}
})
}
}

View File

@ -6,8 +6,8 @@ import (
"fmt"
"strings"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"modelrouter/internal/config"
"modelrouter/internal/lua"
)
// 内核动作实现(R4-c)

View File

@ -6,8 +6,8 @@ import (
"path/filepath"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"modelrouter/internal/config"
"modelrouter/internal/lua"
)
// 本文件验证 R4-c/R4-d 的闭环:**插件自己驱动外部源的链接认证,然后把源接进内核**。
@ -22,12 +22,12 @@ func kernelTestCore(t *testing.T) *Core {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
// 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
// LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
// /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
// MODELROUTER_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
// /etc/modelrouter/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
// 把 26 个测试账号写进了生产 .channels-store.json)。
// 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
if os.Getenv("MODELROUTER_PLUGIN_DIR") == "" {
t.Setenv("MODELROUTER_PLUGIN_DIR", pluginDir)
}
// ★ 显示器号与端口用**高段号**,避开生产。
//
@ -36,8 +36,8 @@ func kernelTestCore(t *testing.T) *Core {
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
// 故测试整体挪到 300+。
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
if os.Getenv("MODELROUTER_XDISPLAY_BASE") == "" {
t.Setenv("MODELROUTER_XDISPLAY_BASE", "300")
}
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),

View File

@ -4,7 +4,7 @@ import (
"testing"
"time"
"llmsproxy/internal/lua"
"modelrouter/internal/lua"
)
// TestNoKernelActionDeadlocksFromLua 对**每个**内核动作做同一条断言:

View File

@ -7,7 +7,7 @@ import (
"testing"
"time"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
// AutoChainFor sits on the request hot path and reads keyAutoChains with no
@ -151,7 +151,7 @@ func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
name := fmt.Sprintf("churn%d", n)
_ = c.AddSource(config.Source{
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
Models: []config.Model{{ID: "m-" + name}}},
)
_ = c.RemoveSource(name)
}

View File

@ -4,8 +4,8 @@ import (
"path/filepath"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/scheduler"
"modelrouter/internal/config"
"modelrouter/internal/scheduler"
)
// Per-key AUTO chains let an admin give one user their own scheduling chain
@ -32,7 +32,7 @@ func perKeyTestConfig(t *testing.T) *config.Config {
GatewayKeys: []string{"sk-gw-admin"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
},
// Global chain points at gpt-4o.
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
@ -198,7 +198,7 @@ func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
// Add the source; the per-key chain must pick it up without a restart.
cfg.Sources = append(cfg.Sources, config.Source{
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
Models: []config.Model{{ID: "new-model", Priority: 10}},
Models: []config.Model{{ID: "new-model"}},
})
if err := c.Reload(); err != nil {
t.Fatalf("Reload: %v", err)

View File

@ -4,7 +4,7 @@ import (
"net/url"
"testing"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
// The plugin outbound allowlist is built from the hosts of configured sources.

View File

@ -6,7 +6,7 @@ import (
"strings"
"testing"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
// Startup must be idempotent, and every credential consumer must see PLAINTEXT.
@ -42,7 +42,7 @@ func writeSealedInstall(t *testing.T, dir string) string {
GatewayKeys: []string{"sk-gw-seed-me"},
Sources: []config.Source{{
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
}},
}
c, err := NewFromConfig(cfg)
@ -76,7 +76,7 @@ func writeSealedInstallNoSeed(t *testing.T, dir string) string {
DefaultModel: "AUTO",
Sources: []config.Source{{
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
}},
}
c, err := NewFromConfig(cfg)

View File

@ -11,7 +11,7 @@ import (
"strings"
"time"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
type adapterPayload struct {
@ -215,7 +215,7 @@ const keepExistingAPIKey = "__KEEP__"
// - the explicit mask keepExistingAPIKey — always inherit.
// - the empty string — inherit only for a source that already has a
// credential. Empty is a legitimate value for a *new* credential-less
// source (dshcodebuddy uses "dsh-local-noauth", local servers use ""),
// source (some local runtimes use a no-auth sentinel, local servers ""),
// so a fresh source with an empty key must stay empty rather than inherit
// nothing and fail differently.
func (g *Gateway) resolveAPIKey(name, want string, exists bool) string {
@ -488,7 +488,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
if to == 0 {
to = time.Now().UnixMilli()
}
csvHeaders(w, "llmsproxy-requests.csv")
csvHeaders(w, "modelrouter-requests.csv")
cw := csv.NewWriter(w)
names := map[string]string{}
for _, k := range g.core.ListKeys() {
@ -537,7 +537,7 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
return
}
if r.URL.Query().Get("export") == "keys-csv" {
csvHeaders(w, "llmsproxy-keys.csv")
csvHeaders(w, "modelrouter-keys.csv")
cw := csv.NewWriter(w)
_ = cw.Write([]string{"key", "key_name", "role", "models", "total_requests", "success_requests", "failed_requests", "prompt_tokens", "completion_tokens", "total_tokens", "avg_latency_ms", "max_latency_ms", "created_at"})
keyFilter := exportKey(r)

View File

@ -31,7 +31,7 @@ import (
"sort"
"strings"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
// apiV1Routes dispatches /api/v1/*. Kept separate from routes() so the original
@ -144,7 +144,7 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
}
index := map[string]interface{}{
"api_version": "v1",
"description": "llmsproxy management API. Authenticate with a gateway key: " +
"description": "modelrouter management API. Authenticate with a gateway key: " +
"'Authorization: Bearer <key>', '?api_key=<key>', or the gw_key cookie. " +
"Read endpoints accept any role; mutations require an admin key.",
"discovery": []ep{

View File

@ -7,7 +7,7 @@ import (
"strings"
"testing"
"llmsproxy/internal/config"
"modelrouter/internal/config"
)
// v1Gateway builds a gateway with one source and two keys, and returns the

Some files were not shown because too many files have changed in this diff Show More