mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Phase 1:内核定义市面所有主流协议字段的**并集**作为唯一内部格式, 适配器按需取用;内核不再携带任何协议形状。 - internal/types/maxwire.go:UnifiedRequest(21 字段,v1 的 10)、 UnifiedResult(11,v1 的 8)、UnifiedDelta。覆盖 openai/anthropic/ gemini/ollama/deepseek 各自必需字段(system 顶层、top_k、stop_sequences、 thinking 预算、parallel_tool_calls、response_format、safety/citations…)。 - 只带**语义**不带**容器**:generationConfig/systemInstruction/options/ num_predict/maxOutputTokens 等协议专属形状一律不进统一格式,由适配器 自行装填。测试 TestNoProtocolContainerLeak 钉死这条。 - 修掉 v1 的私有字段泄漏:disable_thinking/extra_body 曾是内核私有字段, 却被序列化进交给适配器的 body,导致 10 个适配器各自写 `req.disable_thinking = nil` 清理。v2 用 thinking 语义字段替代, TestNoKernelPrivateFieldLeak 为回归。 Phase 0:建立对外输出的**字节级** golden 基线(.snapshots/), 因为现有 e2e 只有 strings.Contains 宽松断言,无法证明 Phase 2 把 OpenAI wire 移交给适配器 serializer 后输出字节未变。 - e2e/wire_golden_test.go:非流式/流式/生图三个 golden, 比对前 scrub 掉 id 与 created(每请求变化),其余逐字节比对。 - 反向验证:注入 `chat.completion.chunk.DRIFTED` 漂移后 golden 确实失败 (证明它不是永远通过的空壳),随后完整恢复。 - 稳定性:连跑 3 次全绿。 验证:go test -tags luajit ./internal/types/ ./e2e/ -run 'TestMax|TestNo|TestWireGolden' 全绿。
193 lines
6.2 KiB
Go
193 lines
6.2 KiB
Go
package e2e
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// mustAddr 选一个空闲端口并立即释放(与 e2e_test.go 的做法一致)。
|
||
func mustAddr(t *testing.T) string {
|
||
t.Helper()
|
||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatalf("pick port: %v", err)
|
||
}
|
||
addr := l.Addr().String()
|
||
l.Close()
|
||
return addr
|
||
}
|
||
|
||
// 本文件建立**字节级**对外输出基线,供统一线格式 v2(对外序列化移交适配器)
|
||
// 做回归比对。
|
||
//
|
||
// 为什么必须有它:Phase 2 要把 gateway/chat.go 里硬编码的 OpenAI wire 结构与
|
||
// writeChatCompletion/pumpStream 的 JSON 拼装移交给适配器 serializer。现有 e2e
|
||
// 只用 strings.Contains 做宽松断言("包含 Hel"),无法证明"输出字节未变"——
|
||
// 字段集、omitempty 裁剪、usage 零值处理都可能静默漂移,而客户端会因此看到
|
||
// 不同的 wire 形状。golden 把当前输出钉死,改动前后逐字节比对。
|
||
//
|
||
// 更新方式:GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden
|
||
// 更新后必须人工审阅 diff —— golden 是契约,不是快照垃圾桶。
|
||
|
||
// volatile 命中每次请求都变的字段,比对前替换为占位符。
|
||
// 允许冒号后的空白:golden 是 pretty-print 过的(`"created": 123`)。
|
||
var volatile = []*regexp.Regexp{
|
||
regexp.MustCompile(`"id":\s*"[^"]*"`),
|
||
regexp.MustCompile(`"created":\s*\d+`),
|
||
}
|
||
|
||
func scrub(s string) string {
|
||
s = volatile[0].ReplaceAllString(s, `"id": "<ID>"`)
|
||
return volatile[1].ReplaceAllString(s, `"created": <TS>`)
|
||
}
|
||
|
||
func compareGolden(t *testing.T, name, got string) {
|
||
t.Helper()
|
||
got = scrub(got)
|
||
path := filepath.Join("..", ".snapshots", name)
|
||
if os.Getenv("GOLDEN_UPDATE") == "1" {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Logf("golden updated: %s", path)
|
||
return
|
||
}
|
||
want, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("golden missing (%s): %v\nrun GOLDEN_UPDATE=1 go test -tags luajit ./e2e/ -run TestWireGolden to create", path, err)
|
||
}
|
||
if got != string(want) {
|
||
t.Errorf("对外输出与 golden 漂移 (%s)\n--- want ---\n%s\n--- got ---\n%s",
|
||
name, string(want), got)
|
||
}
|
||
}
|
||
|
||
// prettyJSON 缩进输出。走 map 会排序键,从而让「字段集」成为唯一被比较的东西,
|
||
// 忽略 Go struct 声明顺序(那不是对外契约的一部分)。
|
||
func prettyJSON(t *testing.T, raw string) string {
|
||
t.Helper()
|
||
var v interface{}
|
||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||
return raw
|
||
}
|
||
out, err := json.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
return raw
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// TestWireGoldenNonStreamChat 钉死非流式 /v1/chat/completions 的字段集。
|
||
func TestWireGoldenNonStreamChat(t *testing.T) {
|
||
up := newMockUpstream(t)
|
||
dir := t.TempDir()
|
||
addr := mustAddr(t)
|
||
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up}))
|
||
|
||
resp, body := g.do("POST", "/v1/chat/completions",
|
||
`{"model":"good/good-m","messages":[{"role":"user","content":"hi"}]}`, true)
|
||
|
||
// 语义:OpenAI 客户端依赖这些顶层字段
|
||
var obj map[string]interface{}
|
||
if err := json.Unmarshal([]byte(body), &obj); err != nil {
|
||
t.Fatalf("not JSON: %v (%s)", err, body)
|
||
}
|
||
for _, k := range []string{"id", "object", "created", "model", "choices", "usage"} {
|
||
if _, ok := obj[k]; !ok {
|
||
t.Errorf("非流式响应缺少 OpenAI 必需字段 %q: %s", k, body)
|
||
}
|
||
}
|
||
if obj["object"] != "chat.completion" {
|
||
t.Errorf("object = %v, want chat.completion", obj["object"])
|
||
}
|
||
if resp.StatusCode != 200 {
|
||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||
}
|
||
|
||
compareGolden(t, "chat_nonstream.json", prettyJSON(t, body))
|
||
}
|
||
|
||
// TestWireGoldenStreamChat 钉死流式 SSE 的帧形状与终止序列。
|
||
func TestWireGoldenStreamChat(t *testing.T) {
|
||
up := newMockUpstream(t)
|
||
dir := t.TempDir()
|
||
addr := mustAddr(t)
|
||
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"good": up}))
|
||
|
||
resp, _ := doStream(t, g, `{"model":"good/good-m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
defer resp.Body.Close()
|
||
|
||
var frames []string
|
||
sc := bufio.NewScanner(resp.Body)
|
||
for sc.Scan() {
|
||
line := sc.Text()
|
||
if !strings.HasPrefix(line, "data: ") {
|
||
continue
|
||
}
|
||
payload := strings.TrimPrefix(line, "data: ")
|
||
if payload == "[DONE]" {
|
||
frames = append(frames, "[DONE]")
|
||
continue
|
||
}
|
||
frames = append(frames, prettyJSON(t, payload))
|
||
}
|
||
if len(frames) < 2 {
|
||
t.Fatalf("期望多帧 SSE,实得 %d: %v", len(frames), frames)
|
||
}
|
||
if frames[len(frames)-1] != "[DONE]" {
|
||
t.Errorf("末帧 = %q, want [DONE]", frames[len(frames)-1])
|
||
}
|
||
// 首帧必须是 role 前导:OpenAI 客户端靠它建立 assistant 消息
|
||
if !strings.Contains(frames[0], `"role"`) {
|
||
t.Errorf("首个 SSE 帧必须携带 role 前导,实得: %s", frames[0])
|
||
}
|
||
|
||
compareGolden(t, "chat_stream.txt", strings.Join(frames, "\n"))
|
||
}
|
||
|
||
// TestWireGoldenImage 钉死 /v1/images/generations 的响应形状。
|
||
func TestWireGoldenImage(t *testing.T) {
|
||
up := newMockUpstream(t)
|
||
dir := t.TempDir()
|
||
addr := mustAddr(t)
|
||
g := startGateway(t, buildBinary(t), addr, writeConfig(t, dir, addr, map[string]*mockUpstream{"image": up}))
|
||
|
||
resp, body := g.do("POST", "/v1/images/generations", `{"model":"imagegen/flux-1","prompt":"a cat"}`, true)
|
||
if resp.StatusCode != 200 {
|
||
t.Fatalf("image status = %d: %s", resp.StatusCode, body)
|
||
}
|
||
if !strings.Contains(body, "QUJD") {
|
||
t.Fatalf("生图响应丢失了载荷: %s", body)
|
||
}
|
||
compareGolden(t, "image_generation.json", prettyJSON(t, body))
|
||
}
|
||
|
||
// doStream 发流式请求并返回未读完的响应(调用方负责逐行读 SSE)。
|
||
func doStream(t *testing.T, g *gatewayUnderTest, body string) (*http.Response, error) {
|
||
t.Helper()
|
||
req, err := http.NewRequest("POST", "http://"+g.addr+"/v1/chat/completions", strings.NewReader(body))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+g.key)
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("stream request: %v", err)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
var _ = io.Discard
|