package lua import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "strings" "testing" "time" ) // 真上游 e2e:用适配器产出的头 + body 直打 opencode /inference/v1。 // 需要真实凭据(从生产配置解密),故用 MODELROUTER_LIVE_ZEN=1 显式开启。 func TestOpencodeZenLiveE2E(t *testing.T) { if os.Getenv("MODELROUTER_LIVE_ZEN") != "1" { t.Skip("设 MODELROUTER_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(gatewayBinary(), "-show-secrets", "-config", "/etc/modelrouter/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("MODELROUTER_LIVE_ZEN") != "1" { t.Skip("设 MODELROUTER_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(gatewayBinary(), "-show-secrets", "-config", "/etc/modelrouter/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)) } // gatewayBinary 定位本机网关二进制。改名后默认是 /usr/local/bin/modelrouter; // 仍兼容改名前的 /usr/local/bin/llmsproxy,避免测试脚本要跟着运维机器改。 func gatewayBinary() string { if v := os.Getenv("MODELROUTER_BIN"); v != "" { return v } for _, p := range []string{"/usr/local/bin/modelrouter", "/usr/local/bin/llmsproxy"} { if _, err := os.Stat(p); err == nil { return p } } return "/usr/local/bin/modelrouter" }