Files
ModelRouter/internal/lua/adapter_opencodezen_test.go
JianFeeeee ddbafe822c chore(main): 回流 —— opencode Zen 免费层可用 + noVNC path 相对名修复
内容与 release/v2.0.x 完全一致(git checkout release/v2.0.x -- .):
- feat(opencode): Zen 免费层门禁四要素 + 两个 id 公式(详见 release 提交)
- fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接
2026-10-15 20:31:45 +08:00

157 lines
5.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 (
"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"])
}