Files
ModelRouter/internal/lua/adapter_opencodezen_live_test.go
JianFeeeee 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

214 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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