mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69da3c9b92 | |||
| 1be34557dd | |||
| 9e5fec08b8 | |||
| 68446fb6fe | |||
| 7e0a428c23 | |||
| 7a8d97001c | |||
| 850920155a | |||
| eaac0ef6b3 |
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" \
|
||||
|
||||
@ -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 重写适配器源码,后续请求自然使用新逻辑(内核重建该适配器
|
||||
|
||||
82
internal/core/channels_orphan_callback_test.go
Normal file
82
internal/core/channels_orphan_callback_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
|
||||
//
|
||||
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
|
||||
// (对应的 .json 早已消失)。它们永远不会被清,因为:
|
||||
//
|
||||
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
|
||||
// · 进程被强杀时 on_unload 没机会跑;
|
||||
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
|
||||
//
|
||||
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
|
||||
// 而不是"某函数被调用过"。
|
||||
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
|
||||
|
||||
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
|
||||
orphans := []string{
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
|
||||
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
|
||||
keep := filepath.Join(pluginDir, ".channels-store.json")
|
||||
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 加载插件 = 走真实启动路径
|
||||
c := kernelTestCore(t)
|
||||
defer c.Close()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
|
||||
for i := 0; i < 50; i++ {
|
||||
left := 0
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
left++
|
||||
}
|
||||
}
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, f := range orphans {
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
|
||||
"(真实事故:生产攒了 18 个)", filepath.Base(f))
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(keep); err != nil {
|
||||
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
|
||||
}
|
||||
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
|
||||
}
|
||||
|
||||
var _ = lua.Plugins{}
|
||||
169
internal/core/channels_trae_pkce_test.go
Normal file
169
internal/core/channels_trae_pkce_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
|
||||
// 而不是去调 ExchangeToken 换一个不存在的码。
|
||||
//
|
||||
// 真实缺陷(用户报「登录后显示失败」):
|
||||
// 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 分支,必须明确报出来。
|
||||
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
|
||||
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
|
||||
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
|
||||
//
|
||||
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
|
||||
// 故判据直接摆好那个文件再调 poll_login。
|
||||
status, body := h.beginAndPollWithCallback(map[string]string{
|
||||
"code": "PKCE_AUTH_CODE_XYZ",
|
||||
})
|
||||
|
||||
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
|
||||
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
|
||||
// 前者写成了后者,于是把正确的实现判成错。
|
||||
if !strings.Contains(body, "state:failed") {
|
||||
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
|
||||
status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
|
||||
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
|
||||
"实际打了 %d 次", n)
|
||||
}
|
||||
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
|
||||
blob := body
|
||||
if !strings.Contains(blob, "PKCE") {
|
||||
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
|
||||
}
|
||||
if strings.Contains(blob, "授权码交换失败") {
|
||||
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
|
||||
}
|
||||
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
|
||||
}
|
||||
|
||||
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
|
||||
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
|
||||
func TestTraeExchangeSendsRealClientID(t *testing.T) {
|
||||
h := newTraeHarness(t)
|
||||
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
|
||||
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
|
||||
status, body := h.beginAndCallback(map[string]string{
|
||||
"refreshToken": "RT-FROM-CALLBACK",
|
||||
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
|
||||
})
|
||||
if status != 200 {
|
||||
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
|
||||
}
|
||||
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
|
||||
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
|
||||
}
|
||||
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
|
||||
}
|
||||
|
||||
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
|
||||
func TestTraeClientIDIsNotBlank(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
|
||||
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
|
||||
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
|
||||
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
|
||||
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
|
||||
b, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := string(b)
|
||||
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
|
||||
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
|
||||
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
|
||||
"必然 400 且把真因掩盖成交换失败")
|
||||
}
|
||||
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
|
||||
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
|
||||
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
|
||||
}
|
||||
// ③ PKCE 判定必须仍在 traeCallback 内
|
||||
if !strings.Contains(src, "本实现暂不支持该流程") {
|
||||
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
|
||||
}
|
||||
}
|
||||
|
||||
// beginAndPollWithCallback 走**真实登录用的那条路径**:
|
||||
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
|
||||
//
|
||||
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
|
||||
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
|
||||
// trae 分支里。
|
||||
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
|
||||
h.t.Helper()
|
||||
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{})
|
||||
sid, _ := sess["id"].(string)
|
||||
nonce, _ := sess["nonce"].(string)
|
||||
if sid == "" {
|
||||
h.t.Fatalf("会话缺 id: %#v", sess)
|
||||
}
|
||||
|
||||
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
|
||||
rec := map[string]string{"state": nonce}
|
||||
for k, v := range cbQuery {
|
||||
rec[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(rec)
|
||||
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
|
||||
if err := os.WriteFile(cbPath, b, 0600); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
|
||||
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
|
||||
"session_id": sid, "file": cbPath,
|
||||
}); err != nil {
|
||||
h.t.Fatalf("设置 loopback 文件: %v", err)
|
||||
}
|
||||
|
||||
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
|
||||
}
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
77
internal/lua/plugin_unload_on_close_test.go
Normal file
77
internal/lua/plugin_unload_on_close_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。
|
||||
//
|
||||
// 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而
|
||||
// on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上,
|
||||
// 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。
|
||||
// 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。
|
||||
//
|
||||
// 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件),
|
||||
// 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。
|
||||
func TestPluginsCloseCallsOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "unloaded.marker")
|
||||
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "leaky", version = "1.0.0", exports = {} }
|
||||
plugin.ui = nil
|
||||
function plugin.on_unload()
|
||||
local f = io.open("` + marker + `", "w")
|
||||
if f then f:write("reaped"); f:close() end
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
// 关之前不该有标记
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("on_unload 在 Close 之前就被调了?")
|
||||
}
|
||||
ps.Close()
|
||||
|
||||
// ★ 真判据:文件必须被写出来(= on_unload 真的跑过)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+
|
||||
"(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err)
|
||||
}
|
||||
t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)")
|
||||
}
|
||||
|
||||
// on_unload 抛错不得让 Close 崩掉(best-effort 契约)。
|
||||
func TestPluginsCloseSurvivesFailingOnUnload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pluginDir := filepath.Join(dir, "plugins")
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := `
|
||||
local plugin = { name = "boom", version = "1.0.0", exports = {} }
|
||||
function plugin.on_unload() error("故意炸") end
|
||||
return plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps := NewPlugins(nil, pluginDir)
|
||||
if err := ps.LoadDir(); err != nil {
|
||||
t.Fatalf("LoadDir: %v", err)
|
||||
}
|
||||
ps.Close() // 不得 panic
|
||||
}
|
||||
@ -572,10 +572,72 @@ func (ps *Plugins) Close() {
|
||||
if ps == nil {
|
||||
return
|
||||
}
|
||||
// ★ 顺序至关重要:**先落盘,再给插件收尾**。
|
||||
//
|
||||
// saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关,
|
||||
// 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的
|
||||
// TestCloseFlushesTail 正是为守住这一点而存在)。
|
||||
//
|
||||
// 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。
|
||||
// Shard VMs are separate golua states; leaving them open leaks a VM per
|
||||
// shard on every reload.
|
||||
ps.shards.Shutdown()
|
||||
ps.save.Close()
|
||||
ps.callOnUnloadAll()
|
||||
}
|
||||
|
||||
// callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。
|
||||
//
|
||||
// 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
|
||||
// 一个没修 —— 这正是本次缺陷的形状(Unload 有、Close 没有)。
|
||||
func (ps *Plugins) callOnUnloadAll() {
|
||||
ps.mu.Lock()
|
||||
plugins := make([]*Plugin, len(ps.plugins))
|
||||
copy(plugins, ps.plugins)
|
||||
ps.mu.Unlock()
|
||||
for _, p := range plugins {
|
||||
ps.callOnUnload(p)
|
||||
}
|
||||
}
|
||||
|
||||
// callOnUnload 调单个插件的 on_unload(存在才调),并关掉它的 Lua state。
|
||||
//
|
||||
// 为什么关 state 也在这里:on_unload 之后 state 就没用了,而 state 一旦关掉,
|
||||
// 钩子再也调不了 —— 调用与关闭必须紧邻,否则又会漏。Unload 与 Close 各自持有
|
||||
// 的 state 生命周期不同(Unload 还要 rebuild,Close 之后整体销毁),所以这里
|
||||
// 只做「调用 + 置空」,由调用方决定其余步骤。
|
||||
func (ps *Plugins) callOnUnload(p *Plugin) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if err := ps.invokeOnUnload(p, p.Info.Name); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", p.Info.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// invokeOnUnload 真正调钩子。抽出来是为了让 Unload 与 Close 共用一份实现
|
||||
// (含 state 判空与错误返回语义),避免两处契约不一致。
|
||||
func (ps *Plugins) invokeOnUnload(p *Plugin, name string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.state == nil || p.state.L == nil {
|
||||
return nil
|
||||
}
|
||||
L := p.state.L
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Pop(1)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "on_unload")
|
||||
var err error
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
err = callFnOn(L, "on_unload")
|
||||
}
|
||||
L.Pop(2) // plugin table + field remnant
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
|
||||
@ -1941,24 +2003,10 @@ func (ps *Plugins) Unload(name string) error {
|
||||
// only guaranteed point where it still can. Best-effort by design — a
|
||||
// hook that errors must not block an unload the operator explicitly asked
|
||||
// for (a wedged plugin has its own path above).
|
||||
p.mu.Lock()
|
||||
if p.state != nil && p.state.L != nil {
|
||||
p.state.L.GetGlobal(pluginGlobal)
|
||||
if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE {
|
||||
L.GetField(-1, "on_unload")
|
||||
if L.Type(-1) == golua.LUA_TFUNCTION {
|
||||
if err := callFnOn(L, "on_unload"); err != nil {
|
||||
log.Printf("[plugins] %s: on_unload: %v", name, err)
|
||||
}
|
||||
}
|
||||
L.Pop(2) // adapter/plugin table + field remnant
|
||||
} else {
|
||||
L.Pop(1)
|
||||
}
|
||||
p.state.L.Close()
|
||||
p.state = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
//
|
||||
// 与 Close 共用 invokeOnUnload:两处各写一遍必然漂移 —— 本次缺陷正是
|
||||
// 「Unload 有、Close 没有」。
|
||||
_ = ps.invokeOnUnload(p, name)
|
||||
ps.rebuild()
|
||||
return nil
|
||||
}
|
||||
@ -2359,6 +2407,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 +2533,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 +2554,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
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -194,7 +194,9 @@ local PLATFORMS = {
|
||||
mode = "trae",
|
||||
exchange_url = "https://api.trae.com.cn/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||
exchange_body = '{"ClientID":"{client_id}","RefreshToken":"{refresh_token}","ClientSecret":"-","UserID":""}',
|
||||
client_id = "", -- 参考用 product.clientId;TRAE 回调直传形态不强制,留空
|
||||
client_id = "en1oxy7wnw8j9n", -- 实测值(对齐参考 trae-product.ts 的 clientId)
|
||||
-- ↑ 这个值**不是**可选的:ExchangeToken 的请求体里 ClientID 是必填,
|
||||
-- 留空会让交换稳定 400(详见文件内 traeCallback 的注释)。
|
||||
},
|
||||
},
|
||||
|
||||
@ -2620,6 +2622,23 @@ plugin.state.log = plugin.state.log or {}
|
||||
-- 字段,先填再被 or {} 覆盖就白填了)。
|
||||
loadState()
|
||||
|
||||
-- ★ 启动清扫:删掉上一进程留下的孤儿回调文件。
|
||||
--
|
||||
-- 为什么需要它:loopbacks 追踪表只活在内存里(plugin.state.loopbacks),
|
||||
-- 进程退出后无从得知上一轮起过哪些接收器。于是进程被强杀/SIGKILL 时,
|
||||
-- 那句 on_unload 没机会跑,磁盘上就永久留下 callback-*.port / .json。
|
||||
-- 实测生产攒了 18 个孤儿 .port(对应的 .json 早已消失),且**永远不会被清** ——
|
||||
-- 重启后内存表是空的,reapLoopback 根本不知道它们存在。
|
||||
--
|
||||
-- 判据不是"文件被删了"这么弱,而是"启动后目录里不再有上一轮的残留":
|
||||
-- internal/core/channels_orphan_callback_test.go。
|
||||
do
|
||||
local dir = storeDir()
|
||||
local sh = io.popen and io.popen("rm -f " .. dir ..
|
||||
"/callback-*.port " .. dir .. "/callback-*.json 2>/dev/null")
|
||||
if sh then sh:close() end
|
||||
end
|
||||
|
||||
local function logEvent(msg)
|
||||
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
|
||||
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
|
||||
@ -4269,11 +4288,28 @@ local function spawnBrowser(start_url)
|
||||
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
|
||||
end
|
||||
b.pids = pids
|
||||
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
|
||||
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
|
||||
-- 同源代理不再需要 vnc_host;后端仍只监听回环。
|
||||
local sess = tostring(seq) .. "-" .. pass:sub(1, 8)
|
||||
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq, sess = sess,
|
||||
expires_at = (os.time and os.time() or 0) + 900 }
|
||||
-- 代理入口:把 noVNC 视图路径经网关回传,而不是让客户端去连 VNC 端口。
|
||||
-- 客户端可能来自另一台机器(127.0.0.1 指向客户端自己)且 websockify 只绑回环,
|
||||
-- 所以只能由网关同端口转发;插件只提供回环目标与绝对路径,不处理传输。
|
||||
local base = "/api/plugins/http/channels/browser/proxy/" .. sess
|
||||
-- noVNC 用 `new URL(path, location.href)` 解析 path(ui.js 的 connect()),
|
||||
-- 即**相对 vnc.html 所在目录**解析,默认值就是相对名 'websockify'。
|
||||
-- 曾传站点根路径(/api/.../websockify)→ 被当相对路径 ⇒ 路径重复拼接:
|
||||
-- .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify
|
||||
-- 于是 WebSocket 永远连不上(noVNC 显示连接失败,页面看似白屏)。
|
||||
-- 只用相对名,与 noVNC 默认值一致。
|
||||
local proxyPath = base .. "/vnc.html?autoconnect=1&resize=scale&password=" .. pass
|
||||
.. "&path=websockify"
|
||||
return {
|
||||
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(
|
||||
host, wport, host, wport, pass),
|
||||
view_url = proxyPath,
|
||||
proxy_path = proxyPath,
|
||||
proxy_url = base .. "/vnc.html",
|
||||
session_id = sess,
|
||||
vnc_target = ("http://127.0.0.1:%d"):format(wport),
|
||||
password = pass,
|
||||
cdp_port = cdpPort,
|
||||
display = disp,
|
||||
@ -4826,25 +4862,19 @@ function plugin.poll_login(req)
|
||||
elseif s.platform == "trae" then
|
||||
-- Trae:回调把 token 直接放在 query 里(refreshToken/userJwt/userInfo),
|
||||
-- 故把整个回调 query 交给 traeCallback(它在两条路径上共用)。
|
||||
--
|
||||
-- ★ 这里曾经有一段**自己发明的**「授权码交换」:回调只带 code 时,拿
|
||||
-- exchange_body 去 ExchangeToken。那是错的,而且是稳定 400:
|
||||
-- · ExchangeToken 的签名是 RefreshToken,不是 code;
|
||||
-- · exchange_body 里根本没有 {code} 占位符,替换是空操作。
|
||||
-- 结果是日志回一句误导性的「授权码交换失败: 400」,把真因
|
||||
-- 「上游走了 PKCE 流程」掩盖成「交换出错了」。
|
||||
--
|
||||
-- 按参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法:
|
||||
-- 带 code 的回调是**合法回调**,只是我们不支持的 PKCE 分支,
|
||||
-- 要**明确报出来**而不是尝试一个不存在的交换。
|
||||
-- traeCallback 已内建这个判定(返回「上游返回了 PKCE 授权码…」)。
|
||||
credential, account, cerr = traeCallback(plat, cb, s)
|
||||
if not credential and code ~= "" then
|
||||
-- 回调只给了 code(PKCE 形态)→ 用 exchange 端点的标准交换
|
||||
local body = (plat.callback.exchange_body or "")
|
||||
:gsub("{client_id}", plat.callback.client_id or "")
|
||||
:gsub("{code}", code)
|
||||
:gsub("{port}", tostring(gatewayPort()))
|
||||
local resp, err = __llmsproxy_http({ url = plat.callback.exchange_url,
|
||||
method = "POST", headers = { ["Content-Type"] = "application/json" }, body = body })
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
cerr = "Trae 授权码交换失败: " .. tostring(err or (resp and resp.status))
|
||||
else
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
local access = ok and type(rec) == "table"
|
||||
and (rec.Token or rec.token or rec.AccessToken or rec.access_token)
|
||||
if not access then cerr = "Trae 授权码交换响应缺少 Token"
|
||||
else credential, account, cerr = json.encode({ access_token = access }), "trae", nil end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Gemini 等标准 OAuth:表单交换(必带 client_secret,见 token_body 注释)
|
||||
local body = (plat.callback.token_body or "")
|
||||
@ -6251,8 +6281,13 @@ plugin.http = {
|
||||
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
|
||||
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
|
||||
roles = { "admin" }, description = "服务端浏览器会话状态" },
|
||||
{ path = "channels/browser/proxy/:session", method = "GET", prefix = true,
|
||||
handler = "browser_proxy", roles = { "admin" },
|
||||
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
|
||||
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
|
||||
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
|
||||
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
|
||||
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
|
||||
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
|
||||
roles = { "admin" }, description = "回收服务端浏览器栈" },
|
||||
}
|
||||
@ -7110,27 +7145,24 @@ function plugin.usage_token_ledger(req)
|
||||
if key ~= "" then
|
||||
url = url .. "&key=" .. (key:len() <= 6 and key or ("***" .. key:sub(-6)))
|
||||
end
|
||||
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000 })
|
||||
local headers = {}
|
||||
if key ~= "" then
|
||||
headers["Authorization"] = "Bearer " .. key
|
||||
end
|
||||
local resp, err = __llmsproxy_http({ url = url, method = "GET", timeout_ms = 4000, headers = headers })
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
return { status = 200, json = { ok = true, snapshot = { available = false,
|
||||
error = err or (resp and tostring(resp.status) or "no response") } } }
|
||||
return { status = 503, json = { ok = false, error = "usage snapshot unavailable: " .. tostring(err or (resp and resp.status or "no response")) } }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return { status = 200, json = { ok = true, snapshot = { available = false, error = "stats 无法解析" } } }
|
||||
end
|
||||
-- 汇总 token 总量(兼容 stats 的不同形状)
|
||||
local prompt, completion, requests = 0, 0, 0
|
||||
local rows = rec.requests or rec.rows or rec.data or {}
|
||||
if type(rows) == "table" then
|
||||
for _, r in ipairs(rows) do
|
||||
if type(r) == "table" then
|
||||
prompt = prompt + (tonumber(r.prompt_tokens) or 0)
|
||||
completion = completion + (tonumber(r.completion_tokens) or 0)
|
||||
requests = requests + 1
|
||||
end
|
||||
end
|
||||
return { status = 502, json = { ok = false, error = "usage snapshot 无法解析" } }
|
||||
end
|
||||
-- 汇总 token 总量。/api/stats 的聚合在 `total` 下,不在顶层数组中。
|
||||
-- 之前错误地遍历 rec.requests/rows/data,导致该端点永远返回 0。
|
||||
local total = type(rec.total) == "table" and rec.total or {}
|
||||
local prompt = tonumber(total.prompt_tokens) or 0
|
||||
local completion = tonumber(total.completion_tokens) or 0
|
||||
local requests = tonumber(total.reqs) or 0
|
||||
return { status = 200, json = { ok = true, snapshot = { available = true,
|
||||
tokens = { prompt = prompt, completion = completion, total = prompt + completion },
|
||||
requests = requests, raw = rec } } }
|
||||
@ -7482,12 +7514,48 @@ function plugin.stop_browser(req)
|
||||
end
|
||||
|
||||
-- browser_status:当前浏览器会话状态(给 UI 显示)。
|
||||
-- reap_loopbacks:回收**所有**回环接收器。
|
||||
--
|
||||
-- 为什么需要显式入口:回环本来只在**会话过期**时由 reapLoopback 收掉
|
||||
-- (sweepSessions 是惰性的,靠读取时顺带做)。于是有一条稳定的泄漏路径:
|
||||
-- 测试或运维起了 login 会话后直接退出,会话从未「过期」⇒ 接收器进程常驻。
|
||||
-- 实测本机积累到 995 个(其中 828 个超过 24 小时),全是 /tmp/Test*/ 下的
|
||||
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
|
||||
--
|
||||
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
|
||||
function plugin.reap_loopbacks(req)
|
||||
local n = 0
|
||||
for sid in pairs(plugin.state.loopbacks or {}) do
|
||||
reapLoopback(sid)
|
||||
n = n + 1
|
||||
end
|
||||
return { status = 200, json = { ok = true, reaped = n } }
|
||||
end
|
||||
|
||||
function plugin.browser_status(req)
|
||||
local b = browserStack()
|
||||
local s = b.session
|
||||
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
|
||||
end
|
||||
|
||||
-- browser_proxy:前缀代理的每次请求入口。
|
||||
-- 只回答「当前会话的回环目标」;回收后 session 为空,地址即失效(404),
|
||||
-- 未运行的会话 id 也不会被转发(防目录穿越与跨会话探测)。
|
||||
function plugin.browser_proxy(req)
|
||||
sweepSessions()
|
||||
local b = browserStack()
|
||||
local s = b.session
|
||||
local want = req and req.params and req.params.session or nil
|
||||
if s and s.expires_at and s.expires_at <= os.time() then
|
||||
reapBrowser()
|
||||
s = nil
|
||||
end
|
||||
if not s or not s.ws or not s.sess or s.sess ~= want then
|
||||
return { status = 404, json = { error = "没有这个浏览器会话" } }
|
||||
end
|
||||
return { proxy = ("http://127.0.0.1:%d"):format(s.ws) }
|
||||
end
|
||||
|
||||
function plugin.inject_platforms(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platforms == nil then args = req end
|
||||
@ -9524,6 +9592,29 @@ function plugin.__store_dir_probe()
|
||||
return { json = { store_dir = storeDir() } }
|
||||
end
|
||||
|
||||
-- plugin.__set_loopback_probe:只给测试用 —— 把某会话的 loopback 回调文件
|
||||
-- 指到测试摆好的路径,以便直接驱动 poll_login 的 callback 分支。
|
||||
--
|
||||
-- 为什么需要它:callback 流的真实登录走 poll_login(读 loopback 文件),
|
||||
-- 而那个文件平时由插件自己起的 python 接收器写入。判据要验的正是**这条路径**
|
||||
-- 的转码/交换行为(真实缺陷曾藏在它的 trae 分支里),但测试不起真实接收器,
|
||||
-- 故需要这个注入点。生产代码不会调用它。
|
||||
function plugin.__set_loopback_probe(args)
|
||||
args = args or {}
|
||||
local sid = tostring(args.session_id or "")
|
||||
local file = tostring(args.file or "")
|
||||
if sid == "" or file == "" then return { json = { error = "need session_id and file" } } end
|
||||
if type(plugin.state.loopbacks) ~= "table" then plugin.state.loopbacks = {} end
|
||||
-- ★ 合并而非覆盖:真实会话的条目里带着 pid/port_file,覆盖掉它们
|
||||
-- 会让 reapLoopback 杀不掉那个 python 接收器 —— 探针自己就成了泄漏源
|
||||
-- (实测:覆盖写法在该测试里留下 1 个孤儿进程,而其他测试已归零)。
|
||||
local cur = plugin.state.loopbacks[sid]
|
||||
if type(cur) ~= "table" then cur = {} end
|
||||
cur.file = file
|
||||
plugin.state.loopbacks[sid] = cur
|
||||
return { json = { ok = true, session_id = sid, file = file, kept_pid = cur.pid or "" } }
|
||||
end
|
||||
|
||||
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
|
||||
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
|
||||
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
|
||||
|
||||
Reference in New Issue
Block a user