Files
ModelRouter/internal/lua/adapter_opencodezen_live_test.go
JianFeeeee 68446fb6fe fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接
真实缺陷(白屏真因之一):view_url 里传了**站点根路径**作为 noVNC 的
path 参数:

  .../proxy/<id>/vnc.html?path=api%2Fplugins%2Fhttp%2Fchannels%2Fbrowser%2Fproxy%2F<id>%2Fwebsockify

但 noVNC 用 `new URL(path, location.href)` 解析它(app/ui.js 的 connect()),
即**相对 vnc.html 所在目录**解析,默认值就是相对名 "websockify"。传根路径
会被当相对路径 ⇒ 实际请求变成路径重复拼接:

  .../proxy/<id>/api/plugins/http/channels/browser/proxy/<id>/websockify

于是 WebSocket 永远连不上,noVNC 显示连接失败,页面看似白屏。

修法:只传相对名 path=websockify,与 noVNC 默认值一致。

★ 判据:必须解析**产品自己给出的 URL**(而不是测试里手拼)。我上一轮验证时
手拼 URL 并省掉了 path 参数(默认值恰好正确)⇒ 掩盖了这个真实缺陷。
新判据断言:path 存在、不以 / 开头、且 ResolveReference 后等于同级 websockify。
2026-10-15 20:30:33 +08:00

214 lines
6.8 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 (
"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 := os.ReadFile("../../adapters/_local/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 := os.ReadFile("../../adapters/_local/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))
}