mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
- 上游按客户端指纹判定免费层资格: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 收口。
162 lines
6.2 KiB
Go
162 lines
6.2 KiB
Go
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"])
|
||
}
|