Compare commits

...

2 Commits

Author SHA1 Message Date
9e5fec08b8 feat(opencode): Zen 免费层可用 —— 门禁四要素 + id 公式逆出
问题:localzen 源(opencode Zen)整个免费池恒 403 FreeTierError
("free tier can only be used from within OpenCode"),space-bunny-free
等免费模型全部失效。

排查(装官方 CLI 抓包 + 逆其本地 opencode.db,见下):门禁是**四条请求形状
条件**,与 TLS 指纹 / UA / 凭据来源 / system 内容 / 消息条数全部无关:

  1. 端点必须是 /inference/v1/chat/completions(原来打的 /zen/v1 恒 403)
  2. 必须同时发 x-opencode-session 与 x-opencode-session-id(值相同)
  3. tools 里必须有一个名为 read 的工具,且其 parameters.properties **非空**
     (实测:抽掉官方 11 个工具中任意一个仍 200,**只有抽掉 read 变 403**;
      properties 里放任意属性名即可,描述/$schema/required 都不需要;
      改成 properties:{} 或只有 type:object 则 403)
  4. stream 必须为 true(同 body 改 false 恒 403)

★ 两个 id 的 12 位 hex 段由**确定公式**给出(逆自 opencode.db 的 session 表,
用 11/12 条真实 id 逐条比对完全一致):

  MASK = 2^48-1 ; K = 0x1afffffffffffe   (注意不是 27<<48,两者差 2)
  session 段 = (K - now_ms*4096) & MASK   ← 倒计时式,Δseg/Δms ≡ -2^12
  request 段 = (now_ms*16) & MASK

  时间容差实测 ≥1 小时(-3600s 仍 200),故 os.time() 的秒精度足够;
  now_ms*4096 ≈ 7.3e15 < 2^53,在 double 安全整数范围内。

实现:
- id 生成换成上述公式(原用 sha256 派生,完全不符)
- 补 x-opencode-session-id;两个头同值
- 会话号按**整分钟桶**取:既满足门禁的倒计时公式,又保留上游前缀缓存命中
  (实测 cached_tokens: 256 生效)
- tools 兜底:客户端没带 tools 时注入带非空 properties 的 read 桩;
  用户自带 read 则原样保留,不覆盖其定义
- 强制 stream=true
- ★ transform_response 支持 SSE 聚合:免费层只能流式,而客户端可能发非流式,
  此时内核按客户端意图走 Chat/transform_response(期望 JSON)⇒ 必须自行把
  SSE 聚合成 chat.completion,否则报 "invalid character 'd'"

配置(已在生产落地):
- localzen base_url: /zen/v1 → /inference/v1(billing 规则同步)
- 免费模型 1 个 → 8 个(含 deepseek-v4.1-flash-free)
- 适配器移入 internal/lua/adapters/,随 deploy.sh 自动投放

判据(4 条):
- TestOpencodeZenAdapterE2E                       头公式/两头部同值/tools 兜底/不覆盖
- TestOpencodeZenAggregatesSSEForNonStreamClient   SSE → chat.completion 聚合
- TestOpencodeZenLiveE2E                          ★真上游:纯对话/自定义system/多轮/算术
- TestOpencodeZenLiveStreamThroughAdapter         ★真上游:完整流解析链(25 chunk)
  (后两条需 LLMSPROXY_LIVE_ZEN=1 + 真实凭据,默认 skip)

生产 e2e 实测:非流式返回 GW-NONSTREAM-OK(usage 含 cached_tokens 256);
流式原生 SSE 透传;多轮+工具返回正确结果(12*12=144 → 144+1=145)。

回归:go test -tags luajit ./... 10 包全绿。

★ 方法论教训(本轮绕了 4 轮、自我推翻 3 次):
- 一次只改一个变量。我多次同时改 system+条数+tools,拟合出"消息条数上限 3"
  这种假规律,后续每次反转都源于此 —— 真相是那些测试**都缺 tools**。
- 重放成功 ≠ 解释成功。第一次逐字节重放就 200,我却当"重放了官方刚发的请求",
  继续追 TLS 指纹/端点/形状。正确做法:一旦重放成功立刻做**单变量消去**。
- "毒药短语"陷阱:我二分到 963/964 字符精度锁定 Never use tools 是触发点,
  但它单独出现全 200 —— 真相是缺 read 的 properties。凡"某内容触发"的规律
  必须交叉验证。
2026-10-15 20:30:33 +08:00
68446fb6fe fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接
真实缺陷(白屏真因之一):view_url 里传了**站点根路径**作为 noVNC 的
path 参数:

  .../proxy/<id>/vnc.html?path=api%2Fplugins%2Fhttp%2Fchannels%2Fbrowser%2Fproxy%2F<id>%2Fwebsockify

但 noVNC 用 `new URL(path, location.href)` 解析它(app/ui.js 的 connect()),
即**相对 vnc.html 所在目录**解析,默认值就是相对名 "websockify"。传根路径
会被当相对路径 ⇒ 实际请求变成路径重复拼接:

  .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify

于是 WebSocket 永远连不上,noVNC 显示连接失败,页面看似白屏。

修法:只传相对名 path=websockify,与 noVNC 默认值一致。

★ 判据:必须解析**产品自己给出的 URL**(而不是测试里手拼)。我上一轮验证时
手拼 URL 并省掉了 path 参数(默认值恰好正确)⇒ 掩盖了这个真实缺陷。
新判据断言:path 存在、不以 / 开头、且 ResolveReference 后等于同级 websockify。
2026-10-15 20:30:33 +08:00
5 changed files with 891 additions and 2 deletions

View File

@ -1,6 +1,7 @@
package core
import (
"net/url"
"strings"
"testing"
@ -60,7 +61,36 @@ func TestChannelsVncStackEndToEnd(t *testing.T) {
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{

View 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))
}

View 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"])
}

View 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

View File

@ -4277,8 +4277,14 @@ local function spawnBrowser(start_url)
-- 客户端可能来自另一台机器(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=" .. urlencode(base:sub(2) .. "/websockify")
.. "&path=websockify"
return {
view_url = proxyPath,
proxy_path = proxyPath,