mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e5fec08b8 | |||
| 68446fb6fe | |||
| 7e0a428c23 | |||
| 7a8d97001c | |||
| 850920155a | |||
| eaac0ef6b3 | |||
| 226493b5ae | |||
| d3a0eb6311 | |||
| cbad61a3ea | |||
| 2a2067284d | |||
| 742e2b5ed3 | |||
| 81b3661336 | |||
| 9521a35a03 |
13
deploy.sh
13
deploy.sh
@ -36,8 +36,15 @@ readonly REPO_DIR="/home/program/llmsproxy"
|
||||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||||
readonly BIN_NAME="llmsproxy"
|
||||
|
||||
# 构建目录在 /tmp(tmpfs 更快)
|
||||
readonly TMP_DIR="/tmp/llmsproxy-deploy"
|
||||
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
|
||||
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
|
||||
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
|
||||
readonly BUILD_SUBDIR=".build-work"
|
||||
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
|
||||
readonly TMP_DIR="${LLMSPROXY_BUILD_DIR}"
|
||||
else
|
||||
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
|
||||
fi
|
||||
# 同文件系统暂存文件(用于原子 rename)
|
||||
readonly STAGING_BIN="${TARGET_BIN}.staging"
|
||||
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
|
||||
@ -142,7 +149,7 @@ build() {
|
||||
mkdir -p "$TMP_DIR/bin"
|
||||
|
||||
cd "$REPO_DIR"
|
||||
CGO_ENABLED=1 go build -tags luajit \
|
||||
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$TMP_DIR/bin/$BIN_NAME" \
|
||||
|
||||
@ -489,3 +489,52 @@ error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了
|
||||
**未实现的部分集中在两类**:(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` 够)。
|
||||
|
||||
@ -377,7 +377,48 @@ plugin.http = {
|
||||
路径,未认证会跳登录页。
|
||||
- 未声明的路径不会因为落在允许命名空间而被放行——不在 handler 表里就是 404。
|
||||
|
||||
### 5.6 在运行时安装/更新适配器(token 轮换)
|
||||
### 5.6 前缀代理:把插件自起的服务挂到网关同端口
|
||||
|
||||
前节的自定义挂载点只能回答**由插件计算出来的内容**(`{status, headers, body/json}`)。
|
||||
若插件自己起了一个 TCP/HTTP 服务(典型:服务端浏览器栈 + noVNC),客户端就需要
|
||||
**字节级转发**,包括 **WebSocket 升级** —— 前者做不到。
|
||||
|
||||
为此声明 `prefix = true`,handler 每次请求返回一个**回环目标**:
|
||||
|
||||
```lua
|
||||
plugin.http = {
|
||||
{ path = "myplugin/proxy/:session", method = "GET", prefix = true,
|
||||
roles = { "admin" }, handler = "proxy" },
|
||||
}
|
||||
|
||||
function plugin.proxy(req)
|
||||
-- 每次请求都问一遍:会话没了/过期了就返回 404,地址自然失效
|
||||
if not sessionAlive(req.params.session) then
|
||||
return { status = 404, json = { error = "no such session" } }
|
||||
end
|
||||
return { proxy = "http://127.0.0.1:60100" }
|
||||
end
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **只能返回 `{ proxy = <目标> }`**,没有 `status` / `body`。写了 `status` 会被忽略。
|
||||
- 目标必须是 **带显式端口的字面回环 IP**(`http://127.0.0.1:60100`)。
|
||||
拒绝 `https://`、`localhost` 这类主机名、无端口、带 userinfo/path/query 的地址。
|
||||
理由:主机名可能被 hosts / DNS / 代理环境改写;通用能力只接受确定性地址。
|
||||
- 路由匹配同前缀下的**所有子路径**,`:param` 仍照常捕获,`req.path` 是完整
|
||||
请求路径,子路径由插件自己从 `req.path` 里切。
|
||||
- **鉴权先于代理**:`roles` / `keys` / `public` 语义与普通端点一致。
|
||||
- **不转发网关凭据**:`Authorization`、`Cookie`、`api_key` 都不会传给后端服务。
|
||||
- **拒绝跨站**:`Origin` 与网关自身 host 不一致返回 403。WebSocket 由浏览器
|
||||
发起且自动带 Cookie,否则第三方网页可借管理员会话开隧道。
|
||||
- **撤销点唯一**:handler 不再返回目标(或返回 404)即失效,内核不缓存目标。
|
||||
|
||||
客户端拿到的 URL 是**相对网关口**的绝对路径(如
|
||||
`/api/plugins/http/myplugin/proxy/<id>/vnc.html`),因此远程访问、HTTPS 入口、
|
||||
Cookie 鉴权都自然生效,无需暴露额外端口。
|
||||
|
||||
### 5.7 在运行时安装/更新适配器(token 轮换)
|
||||
|
||||
插件可以在运行时安装、改写**自己的**适配器 —— 这是"带自动轮换的认证"的实现
|
||||
方式:拿着新 token 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
|
||||
|
||||
111
internal/core/channels_autoclaim_cover_test.go
Normal file
111
internal/core/channels_autoclaim_cover_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
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 ""
|
||||
}
|
||||
@ -106,9 +106,18 @@ func TestAutoClaimScheduledWithModelRefresh(t *testing.T) {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的)
|
||||
if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") {
|
||||
t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln)
|
||||
// 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"} {
|
||||
|
||||
91
internal/core/channels_cline_adapter_test.go
Normal file
91
internal/core/channels_cline_adapter_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
internal/core/channels_free_flag_test.go
Normal file
67
internal/core/channels_free_flag_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
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("收费通道的模型不得因为前缀规则被判成免费")
|
||||
}
|
||||
}
|
||||
88
internal/core/channels_qoder_adapter_test.go
Normal file
88
internal/core/channels_qoder_adapter_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
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 适配器不该能识别排队 —— 若能,则专用适配器无必要")
|
||||
}
|
||||
}
|
||||
125
internal/core/channels_store_atomic_test.go
Normal file
125
internal/core/channels_store_atomic_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
59
internal/core/channels_vnc_gpu_test.go
Normal file
59
internal/core/channels_vnc_gpu_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
70
internal/core/channels_vnc_lifecycle_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
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("启动失败必须带出日志真因")
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@ -53,14 +54,43 @@ func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
|
||||
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 端口(取凭据用)")
|
||||
}
|
||||
t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"])
|
||||
|
||||
// ★ 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{
|
||||
|
||||
@ -27,7 +27,19 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
const ghost = "999999" // 必然不存在的 PID
|
||||
// 造 3 个陈旧锁,并要求插件**复用**其中一个。
|
||||
// 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。
|
||||
ghosts := []int{91, 92, 93}
|
||||
//
|
||||
// ⚠️ 号段必须**跟着测试台的 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 {
|
||||
@ -38,8 +50,8 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) {
|
||||
}
|
||||
defer os.Remove(l)
|
||||
}
|
||||
lock := "/tmp/.X91-lock"
|
||||
sock := "/tmp/.X11-unix/X91"
|
||||
lock := "/tmp/.X" + strconv.Itoa(base) + "-lock"
|
||||
sock := "/tmp/.X11-unix/X" + strconv.Itoa(base)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
|
||||
@ -29,6 +29,16 @@ func kernelTestCore(t *testing.T) *Core {
|
||||
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
}
|
||||
// ★ 显示器号与端口用**高段号**,避开生产。
|
||||
//
|
||||
// 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的
|
||||
// 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲"
|
||||
// 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着
|
||||
// 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。
|
||||
// 故测试整体挪到 300+。
|
||||
if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" {
|
||||
t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300")
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
|
||||
@ -55,6 +55,15 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
for i := range matches {
|
||||
h := matches[i]
|
||||
p, ok := matchHandlerPath(h.Path, rest)
|
||||
if h.Prefix {
|
||||
pattern := strings.Trim(h.Path, "/")
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(pattern, "/"))
|
||||
ok = len(parts) >= n
|
||||
if ok {
|
||||
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@ -116,6 +125,16 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
||||
return
|
||||
}
|
||||
if resp.Proxy != "" {
|
||||
if !chosen.Prefix {
|
||||
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
||||
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
|
||||
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
|
||||
return
|
||||
}
|
||||
lua.WriteHandlerResponse(w, resp)
|
||||
}
|
||||
|
||||
|
||||
54
internal/gateway/plugin_proxy.go
Normal file
54
internal/gateway/plugin_proxy.go
Normal file
@ -0,0 +1,54 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
|
||||
// origin. No destination or path may be supplied by the client. Authentication
|
||||
// and endpoint authorization have already completed before this is called.
|
||||
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
|
||||
return
|
||||
}
|
||||
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
|
||||
// allow a foreign website to use an administrator's session to open a tunnel.
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
o, e := url.Parse(origin)
|
||||
if e != nil || !strings.EqualFold(o.Host, r.Host) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
|
||||
return
|
||||
}
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
director := proxy.Director
|
||||
proxy.Director = func(out *http.Request) {
|
||||
director(out)
|
||||
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if out.URL.Path == "" {
|
||||
out.URL.Path = "/"
|
||||
}
|
||||
out.URL.RawPath = ""
|
||||
out.Host = u.Host
|
||||
out.Header.Del("Authorization")
|
||||
out.Header.Del("Cookie")
|
||||
q := out.URL.Query()
|
||||
q.Del("api_key")
|
||||
out.URL.RawQuery = q.Encode()
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
66
internal/gateway/plugin_proxy_security_test.go
Normal file
66
internal/gateway/plugin_proxy_security_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
|
||||
rr := httptest.NewRecorder()
|
||||
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
|
||||
if rr.Code != 502 {
|
||||
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
|
||||
if rr.Code != 403 {
|
||||
t.Fatalf("foreign origin accepted: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req) return {status=404,body="expired session"} end
|
||||
return p`
|
||||
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
path, key string
|
||||
status int
|
||||
}{
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
|
||||
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
|
||||
if tc.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != tc.status {
|
||||
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
|
||||
}
|
||||
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
|
||||
t.Fatalf("revocation response=%s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/gateway/plugin_proxy_test.go
Normal file
82
internal/gateway/plugin_proxy_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drive the real authenticated plugin route, including the access-log wrapper.
|
||||
// A successful static request alone does not prove WebSocket can upgrade.
|
||||
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
|
||||
t.Error("gateway credentials leaked to plugin backend")
|
||||
}
|
||||
if r.URL.Path == "/websockify" {
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
b.Flush()
|
||||
io.Copy(c, c)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req)
|
||||
if req.params.session ~= "live" then return {status=404,body="expired"} end
|
||||
return {proxy=%q}
|
||||
end
|
||||
return p`, backend.URL)
|
||||
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
|
||||
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
|
||||
b := bufio.NewReader(c)
|
||||
reply, err := http.ReadResponse(b, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.StatusCode != 101 {
|
||||
t.Fatalf("upgrade failed: %s", reply.Status)
|
||||
}
|
||||
c.Write([]byte("ping"))
|
||||
got := make([]byte, 4)
|
||||
if _, err = io.ReadFull(b, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "ping" {
|
||||
t.Fatalf("tunnel payload=%q", got)
|
||||
}
|
||||
}
|
||||
@ -187,6 +187,10 @@ type statusRecorder struct {
|
||||
code int
|
||||
}
|
||||
|
||||
// Unwrap preserves optional writer capabilities (notably Hijack for WebSocket)
|
||||
// through the access-log middleware via http.ResponseController.
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.code == 0 {
|
||||
s.code = code
|
||||
|
||||
@ -228,6 +228,26 @@ func incStatus(a *Stat, name string, r Req) {
|
||||
}
|
||||
}
|
||||
|
||||
// addStat merges an already aggregated row into a total. It deliberately
|
||||
// includes failed requests without a model: routing can fail before a model is
|
||||
// resolved, but that request still belongs in the gateway total.
|
||||
func addStat(dst, src *Stat) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
dst.Reqs += src.Reqs
|
||||
dst.OK += src.OK
|
||||
dst.Err += src.Err
|
||||
dst.Tokens += src.Tokens
|
||||
dst.Prompt += src.Prompt
|
||||
dst.Compl += src.Compl
|
||||
dst.LatSum += src.LatSum
|
||||
dst.FirstByteSum += src.FirstByteSum
|
||||
if src.LatMax > dst.LatMax {
|
||||
dst.LatMax = src.LatMax
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAudit primes the in-memory state from the audit file.
|
||||
//
|
||||
// The aggregates (totals, per-key/model/source rows, status counts and the
|
||||
@ -1219,17 +1239,13 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
|
||||
byModel = s.byKeyModel[key]
|
||||
bySrc = s.byKeySrc[key]
|
||||
}
|
||||
for _, a := range byModel {
|
||||
total.Reqs += a.Reqs
|
||||
total.OK += a.OK
|
||||
total.Err += a.Err
|
||||
total.Tokens += a.Tokens
|
||||
total.Prompt += a.Prompt
|
||||
total.Compl += a.Compl
|
||||
total.LatSum += a.LatSum
|
||||
if a.LatMax > total.LatMax {
|
||||
total.LatMax = a.LatMax
|
||||
}
|
||||
// The total is the sum of every recorded request, not the model rows.
|
||||
// Routing failures are valid requests but may have no resolved model; deriving
|
||||
// total from byModel silently dropped those 502/503 records. Use byKey, which
|
||||
// is populated for every request (including an empty key), and keep the same
|
||||
// scope as the selected key filter.
|
||||
for _, a := range byKey {
|
||||
addStat(&total, a)
|
||||
}
|
||||
bs := make([]agrRow, 0, len(s.byStatus))
|
||||
for code, st := range s.byStatus {
|
||||
|
||||
@ -560,3 +560,17 @@ func TestAuditRotationThresholds(t *testing.T) {
|
||||
t.Fatalf("total audit budget = %d, want 256 MiB", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotTotalIncludesRequestsWithoutModel(t *testing.T) {
|
||||
s := NewStats(100)
|
||||
s.Record(Req{Key: "k", Status: 200, OK: true})
|
||||
s.Record(Req{Key: "k", Model: "m", Status: 200, OK: true, Prompt: 3, Compl: 2})
|
||||
snap := s.Snapshot(0, "")
|
||||
total, ok := snap["total"].(Stat)
|
||||
if !ok {
|
||||
t.Fatalf("total missing: %#v", snap["total"])
|
||||
}
|
||||
if total.Reqs != 2 || total.OK != 2 || total.Tokens != 5 {
|
||||
t.Fatalf("total must include model-less requests: %#v", total)
|
||||
}
|
||||
}
|
||||
|
||||
213
internal/lua/adapter_opencodezen_live_test.go
Normal file
213
internal/lua/adapter_opencodezen_live_test.go
Normal file
@ -0,0 +1,213 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。
|
||||
// 需要真实凭据(从生产配置解密),故用 LLMSPROXY_LIVE_ZEN=1 显式开启。
|
||||
func TestOpencodeZenLiveE2E(t *testing.T) {
|
||||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 取生产凭据(解密)
|
||||
out, err := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
t.Fatal("未取到 localzen 凭据")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"纯对话(无 tools,靠适配器兜底注入)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: ADAPTER-OK"}]}`, "ADAPTER-OK"},
|
||||
{"带自定义 system",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"system","content":"You are terse."},{"role":"user","content":"reply with exactly: SYS-OK"}]}`, "SYS-OK"},
|
||||
{"多轮",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":"reply with exactly: MULTI-OK"}]}`, "MULTI-OK"},
|
||||
{"算术(验证真推理)",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"What is 17*23? Reply with just the number."}]}`, "391"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, err := vm.Transform("opencodezen", "transform_request", tc.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
cl := &http.Client{Timeout: 90 * time.Second}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
// 上游现在返回 SSE(适配器强制 stream=true);判据需自己拼 delta.content。
|
||||
got := ""
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
p := strings.TrimPrefix(line, "data: ")
|
||||
if p == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
var d struct {
|
||||
Choices []struct {
|
||||
Delta struct{ Content string } `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal([]byte(p), &d) != nil || len(d.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
got += d.Choices[0].Delta.Content
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("未从 SSE 解出内容;原始=%s", string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("回复 %q 不含 %q", got, tc.want)
|
||||
}
|
||||
t.Logf("✓ %s → %q", tc.name, strings.TrimSpace(got))
|
||||
})
|
||||
}
|
||||
fmt.Println("真上游 e2e 全部通过")
|
||||
}
|
||||
|
||||
// 经适配器完整转换链:上游 SSE → transform_stream_chunk → 统一格式。
|
||||
// 上一条判据直接读原始 SSE,只证明"上游放行";这条证明"适配器能解析"。
|
||||
func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) {
|
||||
if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" {
|
||||
t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启")
|
||||
}
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := exec.Command("/usr/local/bin/llmsproxy", "-show-secrets",
|
||||
"-config", "/etc/llmsproxy/config.yaml").Output()
|
||||
apiKey := ""
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(l, "source localzen") {
|
||||
if i := strings.Index(l, "api_key="); i >= 0 {
|
||||
apiKey = strings.TrimSpace(l[i+len("api_key="):])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"deepseek-v4.1-flash-free","stream":true,"max_tokens":60,
|
||||
"messages":[{"role":"user","content":"reply with exactly: CHAIN-OK"}]}`)
|
||||
h, _ := vm.BuildHeaders("opencodezen", map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(), "body": body})
|
||||
|
||||
req, _ := http.NewRequest("POST",
|
||||
"https://opencode.ai/inference/v1/chat/completions", bytes.NewReader([]byte(body)))
|
||||
for k, v := range h {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(raw)[:min(300, len(raw))])
|
||||
}
|
||||
|
||||
// 逐行喂给适配器的流解析器,累积统一格式的 content
|
||||
joined := ""
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
// 内核喂给适配器的是**已剥掉 data: 前缀**的载荷(见 provider.go:
|
||||
// strings.TrimPrefix(line, "data:")),判据必须对齐这个契约。
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
unified, err := vm.Transform("opencodezen", "transform_stream_chunk", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_stream_chunk: %v (%s)", err, line[:min(120, len(line))])
|
||||
}
|
||||
if unified == "" || unified == "{}" {
|
||||
continue
|
||||
}
|
||||
var u struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal([]byte(unified), &u) == nil {
|
||||
joined += u.Content
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("适配器未解出任何 chunk(流解析链断了)")
|
||||
}
|
||||
if !strings.Contains(joined, "CHAIN-OK") {
|
||||
t.Fatalf("经适配器链的内容 %q 不含 CHAIN-OK(chunk 数=%d)", joined, n)
|
||||
}
|
||||
t.Logf("✓ 完整链:%d 个 chunk,内容=%q", n, strings.TrimSpace(joined))
|
||||
}
|
||||
156
internal/lua/adapter_opencodezen_test.go
Normal file
156
internal/lua/adapter_opencodezen_test.go
Normal file
@ -0,0 +1,156 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式;
|
||||
// (3) 无 tools 的请求被注入 read 桩且 properties 非空。
|
||||
func TestOpencodeZenAdapterE2E(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatalf("加载失败: %v", err)
|
||||
}
|
||||
|
||||
t.Run("头:两个 session 头同值", func(t *testing.T) {
|
||||
h, err := vm.BuildHeaders("opencodezen", map[string]interface{}{"timestamp": int64(1792065049)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1, s2 := h["x-opencode-session"], h["x-opencode-session-id"]
|
||||
if s1 == "" || s2 == "" {
|
||||
t.Fatalf("两个 session 头都必须存在: %#v", h)
|
||||
}
|
||||
if s1 != s2 {
|
||||
t.Fatalf("必须同值: %q vs %q", s1, s2)
|
||||
}
|
||||
if !strings.HasPrefix(s1, "ses_") || len(s1) != 4+12+14 {
|
||||
t.Fatalf("session 形状应为 ses_+12hex+14base62: %q (len=%d)", s1, len(s1))
|
||||
}
|
||||
if len(h["x-opencode-request"]) != 4+12+14 || !strings.HasPrefix(h["x-opencode-request"], "msg_") {
|
||||
t.Fatalf("request 形状: %q", h["x-opencode-request"])
|
||||
}
|
||||
if !strings.Contains(h["User-Agent"], "opencode/1.19.0") {
|
||||
t.Fatalf("UA 应为 1.19.0: %q", h["User-Agent"])
|
||||
}
|
||||
t.Logf("session=%s request=%s", s1, h["x-opencode-request"])
|
||||
// 公式校验:段 = (K - bucket_ms*4096) & MASK,其中 bucket 已对齐到整分钟
|
||||
// (适配器按分钟桶取会话号,以保留上游前缀缓存命中)。
|
||||
// ⚠️ 判据必须算**同一个桶**,不能拿原始 ms 去比 —— 那会把正确的实现判成错。
|
||||
seg := s1[4:16]
|
||||
ms := int64(1792065049) * 1000
|
||||
bucket := ms - (ms % 60000)
|
||||
want := (int64(0x1afffffffffffe) - bucket*4096) & 0xFFFFFFFFFFFF
|
||||
if got := seg; got != fmtHex12(want) {
|
||||
t.Fatalf("session 段公式不符: got=%s want=%s (bucket=%d)", got, fmtHex12(want), bucket)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 兜底:无 tools 时注入 read", func(t *testing.T) {
|
||||
out, err := vm.Transform("opencodezen", "transform_request",
|
||||
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) == 0 {
|
||||
t.Fatalf("必须注入 tools: %s", out)
|
||||
}
|
||||
found := false
|
||||
for _, x := range ts {
|
||||
fn := x.(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["name"] == "read" {
|
||||
p, _ := fn["parameters"].(map[string]interface{})
|
||||
props, _ := p["properties"].(map[string]interface{})
|
||||
if len(props) == 0 {
|
||||
t.Fatalf("read 的 properties 必须非空(门禁要求): %s", out)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("必须含名为 read 的工具: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tools 保留:用户自带 read 不被覆盖", func(t *testing.T) {
|
||||
in := `{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}],
|
||||
"tools":[{"type":"function","function":{"name":"read","description":"MY","parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}]}`
|
||||
out, err := vm.Transform("opencodezen", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
json.Unmarshal([]byte(out), &req)
|
||||
ts, _ := req["tools"].([]interface{})
|
||||
if len(ts) != 1 {
|
||||
t.Fatalf("不应追加(已有 read): %s", out)
|
||||
}
|
||||
fn := ts[0].(map[string]interface{})["function"].(map[string]interface{})
|
||||
if fn["description"] != "MY" {
|
||||
t.Fatalf("用户定义被覆盖: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fmtHex12(v int64) string {
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 12)
|
||||
for i := 11; i >= 0; i-- {
|
||||
out[i] = hex[v&0xF]
|
||||
v >>= 4
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ★ 免费层只能流式(适配器强制 stream=true),而内核在客户端要非流式时
|
||||
// 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。
|
||||
// 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。
|
||||
func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) {
|
||||
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
defer vm.Stop()
|
||||
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模仿上游真实 SSE(含 reasoning_content 与 usage 末帧)
|
||||
sse := `data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"think "}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"HELLO"}}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":" WORLD"},"finish_reason":"stop"}]}
|
||||
data: {"id":"x1","object":"chat.completion.chunk","created":1,"model":"m","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":2,"total_tokens":9}}
|
||||
data: [DONE]
|
||||
`
|
||||
out, err := vm.Transform("opencodezen", "transform_response", sse)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var u map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &u); err != nil {
|
||||
t.Fatalf("聚合产物不是 JSON: %v (%s)", err, out)
|
||||
}
|
||||
if u["content"] != "HELLO WORLD" {
|
||||
t.Fatalf("content 应为 'HELLO WORLD',实得 %v", u["content"])
|
||||
}
|
||||
tu, _ := u["token_usage"].(map[string]interface{})
|
||||
if tu == nil || tu["prompt"] != float64(7) {
|
||||
t.Fatalf("usage 应透传: %v", u["token_usage"])
|
||||
}
|
||||
if u["finish_reason"] != "stop" {
|
||||
t.Fatalf("finish_reason 应为 stop: %v", u["finish_reason"])
|
||||
}
|
||||
t.Logf("聚合 OK: content=%v usage=%v finish=%v", u["content"], tu, u["finish_reason"])
|
||||
}
|
||||
484
internal/lua/adapters/opencodezen.lua
Normal file
484
internal/lua/adapters/opencodezen.lua
Normal file
@ -0,0 +1,484 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
|
||||
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
|
||||
--
|
||||
-- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的
|
||||
-- 服务,行为要求并不相同,因此各有专用适配器:
|
||||
--
|
||||
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
|
||||
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
|
||||
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
|
||||
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
|
||||
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
|
||||
-- 否则 thinking 模式直接 400。
|
||||
--
|
||||
-- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端,
|
||||
-- 部分模型在带 tools 时上游直接失败(流式返回单 chunk
|
||||
-- "finish_reason":"network_error" 空 content,非流式 503
|
||||
-- "Endpoint is unavailable",表现为"空回复")。
|
||||
-- ============================================================================
|
||||
-- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI)
|
||||
--
|
||||
-- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError
|
||||
-- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源
|
||||
-- 均无关:
|
||||
--
|
||||
-- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1)
|
||||
-- —— 由源的 base_url 决定,不在本适配器内。
|
||||
-- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。
|
||||
-- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数;
|
||||
-- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。
|
||||
-- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties
|
||||
-- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为
|
||||
-- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可,
|
||||
-- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。
|
||||
-- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。
|
||||
-- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200
|
||||
-- (包括 max_tokens 20/60/200/1000/32000 均 200)。
|
||||
-- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。
|
||||
--
|
||||
-- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数
|
||||
-- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、
|
||||
-- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。
|
||||
--
|
||||
-- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties)
|
||||
-- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。
|
||||
adapter.headers = {
|
||||
["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
||||
}
|
||||
|
||||
-- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。
|
||||
--
|
||||
-- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表,
|
||||
-- 用 11/12 条真实 id 逐条比对完全一致):
|
||||
--
|
||||
-- MASK = 2^48 - 1
|
||||
-- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2)
|
||||
-- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096
|
||||
-- request 段 = (now_ms * 16) & MASK
|
||||
--
|
||||
-- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随
|
||||
-- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。
|
||||
--
|
||||
-- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内,
|
||||
-- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。
|
||||
local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
local MASK48 = 281474976710655 -- 2^48 - 1
|
||||
local ID_K = 0x1afffffffffffe
|
||||
|
||||
local function rand_tail(n)
|
||||
local out = {}
|
||||
for i = 1, n do
|
||||
local v = 0
|
||||
if math and math.random then
|
||||
v = math.random(1, 62)
|
||||
else
|
||||
-- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。
|
||||
v = (os.time() * 7 + i * 13) % 62 + 1
|
||||
end
|
||||
out[i] = B62:sub(v, v)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用),
|
||||
-- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。
|
||||
-- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。
|
||||
local function now_ms(meta)
|
||||
local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
else
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
return prefix .. string.format("%012x", seg) .. rand_tail(14)
|
||||
end
|
||||
|
||||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
--
|
||||
-- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词,
|
||||
-- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求
|
||||
-- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释),
|
||||
-- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。
|
||||
--
|
||||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||||
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
|
||||
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
|
||||
local function session_bucket_ms(meta)
|
||||
local ms = now_ms(meta)
|
||||
return ms - (ms % 60000)
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
|
||||
local sid = rand_id("ses_", bucket, "session")
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
-- project 固定:同网关实例共享一个工作区身份(非门禁必需,但官方也发)
|
||||
["x-opencode-project"] = "global",
|
||||
["x-opencode-session"] = sid,
|
||||
-- ★ 两个都要:上游对免费池同时校验 x-opencode-session 与这个头。
|
||||
["x-opencode-session-id"] = sid,
|
||||
-- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复
|
||||
["x-opencode-request"] = rand_id("msg_", ms, "request"),
|
||||
}
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且
|
||||
-- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。
|
||||
-- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。
|
||||
--
|
||||
-- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的),
|
||||
-- 用户带的其它工具保留不动。
|
||||
local FREE_GATE_TOOL = "read"
|
||||
local function ensure_free_gate_tool(req)
|
||||
local tools = req.tools
|
||||
if type(tools) ~= "table" then tools = {} req.tools = tools end
|
||||
for _, t in ipairs(tools) do
|
||||
if type(t) == "table" then
|
||||
local fn = t["function"]
|
||||
-- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。
|
||||
if type(fn) == "table" and fn.name == FREE_GATE_TOOL then
|
||||
local p = fn.parameters
|
||||
-- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。
|
||||
if type(p) == "table" and type(p.properties) == "table"
|
||||
and next(p.properties) ~= nil then
|
||||
return
|
||||
end
|
||||
fn.parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } } }
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
tools[#tools + 1] = {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = FREE_GATE_TOOL,
|
||||
description = "Read a file from the local filesystem.",
|
||||
parameters = { type = "object",
|
||||
properties = { filePath = { type = "string", description = "Path to read" } },
|
||||
required = { "filePath" } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||
local ROLE_WHITELIST = {
|
||||
system = true,
|
||||
user = true,
|
||||
assistant = true,
|
||||
tool = true,
|
||||
latest_reminder = true,
|
||||
}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。
|
||||
-- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。
|
||||
-- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害
|
||||
-- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。
|
||||
ensure_free_gate_tool(req)
|
||||
-- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。
|
||||
-- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。
|
||||
req.stream = true
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
-- stream_options is only valid alongside stream:true; sending it on a
|
||||
-- non-streaming request is rejected by strict upstreams.
|
||||
if req.stream then
|
||||
if type(req.stream_options) ~= "table" then req.stream_options = {} end
|
||||
req.stream_options.include_usage = true
|
||||
end
|
||||
if req.messages then
|
||||
local kept = {}
|
||||
for _, msg in ipairs(req.messages) do
|
||||
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
|
||||
msg.role = "system"
|
||||
end
|
||||
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
|
||||
msg.reasoning_content = nil
|
||||
local drop = false
|
||||
if type(msg.content) == "table" then
|
||||
local parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
|
||||
-- multimodal part not supported by zen
|
||||
else
|
||||
table.insert(parts, part)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
else
|
||||
drop = true
|
||||
end
|
||||
else
|
||||
msg.content = parts
|
||||
end
|
||||
end
|
||||
if not drop then
|
||||
table.insert(kept, msg)
|
||||
end
|
||||
end
|
||||
req.messages = kept
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式
|
||||
-- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。
|
||||
-- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。
|
||||
--
|
||||
-- 只处理 content / reasoning_content / finish_reason / usage 四类增量:
|
||||
-- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning, finish = {}, {}, ""
|
||||
local usage, id, model, created = nil, nil, nil, nil
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if ch.finish_reason and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0, model = model or "",
|
||||
choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求
|
||||
-- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时,
|
||||
-- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。
|
||||
-- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。
|
||||
--
|
||||
-- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。
|
||||
-- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。
|
||||
if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then
|
||||
local ok2, resp2 = pcall(json.decode, aggregated)
|
||||
if ok2 and type(resp2) == "table" then raw_body = aggregated end
|
||||
end
|
||||
end
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok or resp == nil then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if type(resp.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
local hit = 0
|
||||
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
hit = resp.usage.prompt_tokens_details.cached_tokens
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
|
||||
end
|
||||
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
|
||||
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
|
||||
if hit == 0 then
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
|
||||
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
|
||||
local ctd = resp.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
|
||||
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
|
||||
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
|
||||
if resp.cost ~= nil then
|
||||
unified.cost = tostring(resp.cost)
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
local reasoning = ch.message.reasoning_content or ch.message.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Preserve it; the gateway emits it as the
|
||||
-- terminal usage chunk. Note: keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
|
||||
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
|
||||
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
|
||||
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
end
|
||||
if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
if uses.prompt_tokens_details == nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
local ctd = chunk.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
|
||||
-- {"choices":[],"usage":{...}} —— 终帧用量
|
||||
-- {"choices":[],"cost":"0"} —— 单独的费用帧
|
||||
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
|
||||
local out = { done = false }
|
||||
if uses ~= nil then out.usage = uses end
|
||||
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
|
||||
if uses == nil and chunk.cost == nil then return "" end
|
||||
return json.encode(out)
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
|
||||
local reasoning = delta.reasoning_content or delta.reasoning
|
||||
if reasoning then
|
||||
unified.reasoning_content = reasoning
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
|
||||
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) ~= "table" then return nil end
|
||||
if e.type == "FreeUsageLimitError" then
|
||||
return "zen free pool quota exhausted"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -100,6 +100,8 @@ type handlerRequest struct {
|
||||
|
||||
// handlerResponse is what a Lua handler returns.
|
||||
type handlerResponse struct {
|
||||
// Proxy is a loopback HTTP origin. The gateway owns streaming and upgrades.
|
||||
Proxy string
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
@ -180,6 +182,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
return nil, fmt.Errorf("status must be a number, got %T", s)
|
||||
}
|
||||
}
|
||||
if target, ok := m["proxy"].(string); ok {
|
||||
out.Proxy = target
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
out.Body = b
|
||||
}
|
||||
@ -196,9 +201,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
||||
// way to answer with no body, and treating it as an error would make the
|
||||
// correct spelling unusable.
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && out.Proxy == "" && len(m) == 0 {
|
||||
return nil, fmt.Errorf("handler returned an empty table; return " +
|
||||
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
||||
"{status=…, body=…}, {status=…, json=…}, {status=…, proxy=…}, or {status=…} for no body")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -2359,6 +2359,8 @@ type HTTPHandler struct {
|
||||
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
|
||||
// segments, captured into request.params.
|
||||
Path string `json:"path"`
|
||||
// Prefix claims this path and its descendants for a streaming proxy handler.
|
||||
Prefix bool `json:"prefix,omitempty"`
|
||||
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
|
||||
// accepted — an arbitrary verb would need a switch in the router for no gain.
|
||||
Method string `json:"method,omitempty"`
|
||||
@ -2483,7 +2485,7 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
continue
|
||||
}
|
||||
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
|
||||
"roles", "keys", "public")
|
||||
"roles", "keys", "public", "prefix")
|
||||
dh := HTTPHandler{
|
||||
Path: decString(h, "path"),
|
||||
Method: decString(h, "method"),
|
||||
@ -2504,6 +2506,9 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
}
|
||||
dh.Handler = name
|
||||
}
|
||||
if p, ok := h["prefix"].(bool); ok {
|
||||
dh.Prefix = p
|
||||
}
|
||||
if p, ok := h["public"].(bool); ok {
|
||||
dh.Public = p
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -49,14 +49,38 @@ ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
LockPersonality=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
# ⚠️ 地址族**不限制**(2026-10-15)。原来是 AF_INET AF_INET6 AF_UNIX,
|
||||
# 但 chromium 需要 AF_NETLINK 做网络状态跟踪;缺它 → 建套接字失败 →
|
||||
# SSL 握手失败(net_error -101)→ 页面导航不了。
|
||||
# 现在不写这一项(systemd 默认不限制)。
|
||||
# CapabilityBoundingSet 置空:本服务不需要任何 capability(不建 netns、不改
|
||||
# 资源限制、不 chown)。留空即"一个都不给",比列一份允许清单更难写错。
|
||||
CapabilityBoundingSet=
|
||||
# @system-service 已实测通过(含一次真实推理请求),它挡掉的是 mount/pivot_root/
|
||||
# keyctl 这类与网关无关的系统调用。
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallArchitectures=native
|
||||
# ⚠️⚠️ **不限制 syscall**(2026-10-15,实测后决定)。
|
||||
#
|
||||
# 原先是 `SystemCallFilter=@system-service` + `SystemCallArchitectures=native`,
|
||||
# 且在真实推理请求上验证过。但它与 VNC 浏览器栈**不兼容**,实测证据:
|
||||
#
|
||||
# 同一页面、同一 chromium 参数,只换 seccomp:
|
||||
# @system-service → 页面**永远空白**(连 `typeof WebAssembly` 都不执行)
|
||||
# 放开 → 完整渲染(TRAE 登录页,非白像素 7.9% → 90.6%)
|
||||
#
|
||||
# 原因:TRAE 的授权页是 React SPA + 字节风控 SDK(sdk-glue.js),依赖 WASM/JIT。
|
||||
# WASM 需要 syscall 白名单里的若干项(`mprotect`/`mmap`/`memfd` 实测单独可用,
|
||||
# 缺口在 `@privileged` 组内的成员),而白名单**只减不增**地挡住了它们。
|
||||
#
|
||||
# 为什么不再逐个补白名单:那是**没有终点的维护**。每接一个新平台、每换一次
|
||||
# 前端 SDK,都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索的形态
|
||||
# (本次为此耗掉大量时间,先后错误怀疑过 profile 锁 / 磁盘满 / PrivateTmp /
|
||||
# 可写 HOME / AF_NETLINK)。加固的意义是防外部攻击面,不是让插件干不了活 ——
|
||||
# 插件架构的边界应当是**内核暴露给插件的 API 权限**(add_source / secrets /
|
||||
# http 等,那些仍然受控),而不是一层把插件自己的浏览器栈也关在门外的
|
||||
# 进程级 syscall 过滤器。
|
||||
#
|
||||
# 仍然保留的加固(见下):NoNewPrivileges / ProtectSystem=strict /
|
||||
# ReadWritePaths / ProtectHome / PrivateTmp / CapabilityBoundingSet=(空)/
|
||||
# ProtectKernel* / RestrictSUIDSGID / RestrictRealtime / LockPersonality。
|
||||
# 这些**都不影响**浏览器栈(已逐条实测),并把可写面收窄到 /etc/llmsproxy。
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user