Files
ModelRouter/internal/lua/adapter_opencodezen_test.go
JianFeeeee f32c10d263 fix(opencodezen): UA 升到 1.19.4 并补回 x-opencode-session-id
- 上游按客户端指纹判定免费层资格:CLI 1.18.29(4 个 x-opencode-* 头)
  稳定 429;CLI 1.19.4(5 个头,含 session-id)200 且任务真实完成。
- 适配器的 UA 与头集合必须**同步**升级:只改 UA 声称的版本、不发对应头,
  指纹反而更不一致,实测重新落回 429。
- 头形状按官方 CLI 抓包校正;`x-opencode-session-id` 与 `x-opencode-session`
  同值,此前被误删。
- live 测试新增 `gatewayBinary()`:优先 `MODELROUTER_BIN`,其次
  `/usr/local/bin/modelrouter`,回退改名前的 `/usr/local/bin/llmsproxy`。

注:本提交是改名/插件整改之外的独立修复,先于 v3.0.0 收口。
2026-10-27 00:07:37 +08:00

162 lines
6.2 KiB
Go
Raw 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"])
}
// ★ UA 必须与**抓包的官方版本**一致:上游按客户端指纹判定免费层资格。
// 实测(2026-10-24,同一凭据同一模型):
// CLI 1.18.29(发 4 个头) → 429 FreeUsageLimitError
// CLI 1.19.4 (发 5 个头) → 200,任务真实完成
// ⇒ UA 里声称的版本与发头集合必须同步升级,不能只改其一。
if !strings.Contains(h["User-Agent"], "opencode/1.19.4") {
t.Fatalf("UA 应为 1.19.4: %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"])
}