From 3ad46ea23eb54ca3144a2b61b3bea966226a400d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 11 Oct 2026 08:24:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(types):=20=E7=BB=9F=E4=B8=80=E7=BA=BF?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=20v2=20=E6=9C=80=E5=A4=A7=E9=9B=86=E5=90=88?= =?UTF-8?q?=20+=20=E5=AF=B9=E5=A4=96=E8=BE=93=E5=87=BA=20golden=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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' 全绿。 --- .snapshots/chat_nonstream.json | 24 ++++ .snapshots/chat_stream.txt | 95 +++++++++++++++ .snapshots/image_generation.json | 8 ++ e2e/wire_golden_test.go | 192 +++++++++++++++++++++++++++++ internal/types/maxwire.go | 175 ++++++++++++++++++++++++++ internal/types/maxwire_test.go | 203 +++++++++++++++++++++++++++++++ 6 files changed, 697 insertions(+) create mode 100644 .snapshots/chat_nonstream.json create mode 100644 .snapshots/chat_stream.txt create mode 100644 .snapshots/image_generation.json create mode 100644 e2e/wire_golden_test.go create mode 100644 internal/types/maxwire.go create mode 100644 internal/types/maxwire_test.go diff --git a/.snapshots/chat_nonstream.json b/.snapshots/chat_nonstream.json new file mode 100644 index 0000000..1d189bd --- /dev/null +++ b/.snapshots/chat_nonstream.json @@ -0,0 +1,24 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "pong", + "role": "assistant" + } + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion", + "usage": { + "completion": 1, + "completion_tokens": 1, + "prompt": 3, + "prompt_tokens": 3, + "total": 4, + "total_tokens": 4 + } +} \ No newline at end of file diff --git a/.snapshots/chat_stream.txt b/.snapshots/chat_stream.txt new file mode 100644 index 0000000..8fec9d4 --- /dev/null +++ b/.snapshots/chat_stream.txt @@ -0,0 +1,95 @@ +{ + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "Hel", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "lo", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [ + { + "delta": { + "content": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk" +} +{ + "choices": [], + "created": , + "id": "", + "model": "good-m", + "object": "chat.completion.chunk", + "usage": { + "completion": 1, + "completion_tokens": 1, + "prompt": 16, + "prompt_tokens": 16, + "total": 17, + "total_tokens": 17 + } +} +[DONE] \ No newline at end of file diff --git a/.snapshots/image_generation.json b/.snapshots/image_generation.json new file mode 100644 index 0000000..4fc300b --- /dev/null +++ b/.snapshots/image_generation.json @@ -0,0 +1,8 @@ +{ + "created": , + "data": [ + { + "b64_json": "QUJD" + } + ] +} \ No newline at end of file diff --git a/e2e/wire_golden_test.go b/e2e/wire_golden_test.go new file mode 100644 index 0000000..3945be8 --- /dev/null +++ b/e2e/wire_golden_test.go @@ -0,0 +1,192 @@ +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": ""`) + return volatile[1].ReplaceAllString(s, `"created": `) +} + +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 diff --git a/internal/types/maxwire.go b/internal/types/maxwire.go new file mode 100644 index 0000000..d460726 --- /dev/null +++ b/internal/types/maxwire.go @@ -0,0 +1,175 @@ +// Package types — 统一线格式 v2(最大集合 / maximal set) +// +// 设计原则(feature/unified-wire-v2): +// +// 内核只调度,不知道任何具体协议。 +// 适配器负责两个方向的协议转换: +// 入向 上游原生格式 → 本文件的统一格式 +// 出向 本文件的统一格式 → 对外服务协议 +// +// 因此本文件定义的是**市面所有主流 LLM 协议所需字段的并集**: +// OpenAI Chat Completions / Responses、Anthropic Messages、Gemini +// generateContent、Ollama、DeepSeek(reasoning_content)等。 +// 适配器**按需取用**——每个具体协议都是这个超集的一个子集。 +// +// 命名约定(重要): +// - 字段名取"该能力在业界最广为人知的拼写",便于适配器直接映射, +// 而不是为中立而中立地另造一套名字(那会让每个适配器都要维护一张 +// 翻译表,反而更容易出错)。 +// - 但**协议专属容器**(如 Gemini 的 generationConfig、Ollama 的 options) +// 不进入统一格式:它们是"上游协议的形状",属于适配器的内部事务。 +// 统一格式只保留**语义**(temperature / top_k / max_tokens …), +// 由适配器决定装进哪个容器。 +// +// 与 v1 的关键差异: +// - 内核私有字段不再泄漏给适配器(v1 的 disable_thinking / extra_body +// 会被序列化进请求体,10 个适配器被迫各自 = nil 清理) +// - 新增 Anthropic/Gemini/Ollama 需要的字段(system / top_k / +// stop_sequences / safety_settings / seed …),使适配器不必再从 +// extra_body 这种旁路偷字段 +package types + +import "encoding/json" + +// UnifiedRequest 是内核交给适配器的**完整请求**(最大集合)。 +// +// 适配器只读它需要的字段;未用到的字段是"内核提供了但你这条协议不用", +// 不是"内核不知道该字段"。这个方向的信息损失为零。 +type UnifiedRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + + // ---- 采样参数(所有协议都有,名称归一) ---- + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` // Gemini/Ollama/Anthropic 有 + MaxTokens int `json:"max_tokens,omitempty"` // Anthropic 必需 + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` // OpenAI + PresencePenalty *float64 `json:"presence_penalty,omitempty"` // OpenAI + Seed *int64 `json:"seed,omitempty"` // OpenAI/Gemini + + // ---- 停止条件 ---- + Stop []string `json:"stop,omitempty"` // OpenAI + StopSequences []string `json:"stop_sequences,omitempty"` // Anthropic + + // ---- 工具调用 ---- + Tools []ToolDef `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // OpenAI + + // ---- 结构化输出 ---- + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` // OpenAI + + // ---- 系统提示 ---- + // Anthropic 与 Gemini 都把 system 放在顶层而非 messages 里; + // OpenAI 系则放进 messages[role=system]。统一格式同时提供两条路: + // System 保存语义,Messages 里可能也有一条 role=system。 + // 适配器按自己的协议取用(Anthropic 取 System 并递归剥离 messages 里的 + // system;OpenAI 直接用 messages)。 + System string `json:"system,omitempty"` + + // ---- 推理/思考 ---- + // Thinking 是"要不要思考、思考预算多少"的语义; + // 适配器各自映射(Anthropic thinking.budget_tokens、Gemini + // thinkingConfig.thinkingBudget、DeepSeek extra_body.thinking.type)。 + Thinking *ThinkingConfig `json:"thinking,omitempty"` + + // ---- 多模态 ---- + // 图片/音频等随 message content 的 parts 传递(见 ChatMessage.Content), + // 这里放协议级的生成参数(如 Gemini candidateCount、OpenAI n)。 + GenerationCount int `json:"n,omitempty"` + + // ---- 厂商扩展逃生舱 ---- + // ExtraBody 是**明确标注的**厂商透传通道:客户端可以把它不认识的字段 + // 塞进来,适配器自行决定是否读取。它不参与"内核私有字段"泄漏问题, + // 因为它的语义就是"客户端原样要求的额外字段"。 + // + // 与 v1 的区别:v1 的 ExtraBody 会被 MarshalJSON 手工注入到 OpenAI 形状 + // 的顶层,从而和内核私有字段混在一起;v2 里它是统一格式里的一个**明确定义 + // 的字段**,内核不再需要 MarsalJSON 后门。 + ExtraBody map[string]any `json:"extra_body,omitempty"` + + // ---- 网关内部(不序列化,不交给适配器 body) ---- + // ClientSession 只用于让适配器生成稳定的上游会话,不能出现在上游 body 里。 + ClientSession string `json:"-"` + // Stream 由网关按客户端请求设置;适配器负责映射到上游的流式开关。 + Stream bool `json:"stream,omitempty"` +} + +// ThinkingConfig 是推理预算的语义表示(跨协议)。 +// +// Enabled=false 表示客户端明确要求关闭思考。不同上游的关闭方式不一 +// (DeepSeek 用 extra_body.thinking.type=disabled,Anthropic 直接不传 +// thinking 字段,Gemini 用 thinkingBudget=0),映射由适配器负责。 +type ThinkingConfig struct { + Enabled *bool `json:"enabled,omitempty"` + BudgetTokens int64 `json:"budget_tokens,omitempty"` +} + +// ToolDef 是工具声明的统一形状(OpenAI 的 {type,function} 外壳被摊平)。 +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // Parameters 是 JSON Schema,原样透传。 + Parameters json.RawMessage `json:"parameters,omitempty"` + // Type 保留协议侧的 kind(OpenAI 恒为 "function";其它协议可能为空)。 + Type string `json:"type,omitempty"` +} + +// ResponseFormat 是结构化输出的统一形状。 +type ResponseFormat struct { + Type string `json:"type"` // text | json_object | json_schema + JSONSchema json.RawMessage `json:"json_schema,omitempty"` +} + +// UnifiedResult 是适配器从上游响应转换出来的**统一结果**(最大集合)。 +// +// 这是 v1 的 UnifiedResponse 的超集:v1 只有 content/finish_reason/ +// token_usage/tool_calls;v2 补齐各协议会产出的其它语义。 +type UnifiedResult struct { + Model string `json:"model,omitempty"` + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + + // FinishReason 是**归一后**的结束原因:stop / length / tool_calls / + // content_filter / 空。适配器负责把上游措辞映射到这套词表 + // (如 Anthropic 的 end_turn→stop、max_tokens→length、 + // tool_use→tool_calls;Gemini 的 STOP→stop、MAX_TOKENS→length)。 + FinishReason string `json:"finish_reason,omitempty"` + + TokenUsage TokenUsage `json:"token_usage"` + + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + + // ---- 其它协议可能产出的语义 ---- + // Citations 是 Anthropic 的引用块、Gemini 的 grounding。 + Citations []json.RawMessage `json:"citations,omitempty"` + // Refusal 是 OpenAI 的 refusal 字段(内容被安全策略拒绝)。 + Refusal string `json:"refusal,omitempty"` + // SafetyRatings 是 Gemini 的安全评分。 + SafetyRatings []json.RawMessage `json:"safety_ratings,omitempty"` + + // ImageData 供生图适配器使用。 + ImageData []ImageData `json:"image_data,omitempty"` + + // Cost 是上游自报的费用(小数**字符串**),原样透传。 + // 刻意不在内核里解析或累加:它是上游事实,且只有部分上游提供。 + Cost string `json:"cost,omitempty"` +} + +// UnifiedDelta 是流式分块的统一形状(最大集合)。 +type UnifiedDelta struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Done bool `json:"done"` + // FinishReason 只在终止块上有值。 + FinishReason string `json:"finish_reason,omitempty"` + // ToolCalls 是流式工具调用的增量片段(原始 JSON,由适配器决定形状)。 + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + // Usage 出现在带用量的块上(通常是终止块)。 + Usage *TokenUsage `json:"usage,omitempty"` + // Citations 流式引用块。 + Citations []json.RawMessage `json:"citations,omitempty"` + // Cost 上游自报费用。 + Cost string `json:"cost,omitempty"` +} diff --git a/internal/types/maxwire_test.go b/internal/types/maxwire_test.go new file mode 100644 index 0000000..ebba5c9 --- /dev/null +++ b/internal/types/maxwire_test.go @@ -0,0 +1,203 @@ +package types + +import ( + "encoding/json" + "testing" +) + +// 本文件验证统一线格式 v2 的"最大集合"性质。 +// +// 核心断言不是"这些字段能序列化"(那是废话),而是: +// 1. 每个市面协议需要的字段都在超集里(否则适配器会退化成从 +// extra_body 旁路偷字段 —— v1 就是这么烂掉的) +// 2. 内核私有字段不会出现在交给适配器的 body 里(v1 的泄漏) +// 3. 超集不会把某个协议的**容器形状**带进来(那是适配器的内部事务) + +// TestMaximalSetCoversEveryProtocol 逐协议核对字段覆盖。 +// +// 每个子测试对应一个真实协议,列出该协议**必需**的请求字段。 +// 少一个就说明超集不完整,适配器将被迫走旁路。 +func TestMaximalSetCoversEveryProtocol(t *testing.T) { + // 用 JSON 标签作为"字段存在"的判据,因为适配器看到的就是标签名。 + raw, err := json.Marshal(UnifiedRequest{ + Model: "m", + Messages: []ChatMessage{ + {Role: "user", Content: StringContent("hi")}, + }, + // 把所有可空字段都填上,确保 omitempty 不会掩盖它们的存在 + Temperature: f64(0.7), + TopP: f64(0.9), + TopK: 40, + MaxTokens: 1024, + FrequencyPenalty: f64(0.1), + PresencePenalty: f64(0.2), + Seed: i64(42), + Stop: []string{"\n"}, + StopSequences: []string{"\n\n"}, + Tools: []ToolDef{{Name: "f", Type: "function"}}, + ToolChoice: "auto", + ParallelToolCalls: b(true), + ResponseFormat: &ResponseFormat{Type: "json_object"}, + System: "you are helpful", + Thinking: &ThinkingConfig{Enabled: b(true), BudgetTokens: 2048}, + GenerationCount: 2, + ExtraBody: map[string]any{"vendor_flag": true}, + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + + protocols := map[string][]string{ + "openai": {"model", "messages", "temperature", "top_p", "max_tokens", + "frequency_penalty", "presence_penalty", "seed", "stop", "tools", + "tool_choice", "parallel_tool_calls", "response_format", "n"}, + "anthropic": {"model", "messages", "max_tokens", "temperature", "top_p", + "top_k", "stop_sequences", "system", "thinking", "tools", "tool_choice"}, + "gemini": {"contents-placeholder", "temperature", "top_p", "top_k", + "max_tokens", "stop", "system", "thinking", "tools", "n"}, + "ollama": {"model", "messages", "temperature", "top_p", "top_k", + "max_tokens", "stop", "tools"}, + "deepseek": {"model", "messages", "temperature", "top_p", "max_tokens", + "stop", "tools", "thinking"}, + } + for proto, need := range protocols { + for _, f := range need { + if f == "contents-placeholder" { + continue // Gemini 的 contents 由适配器从 messages 构造,不是统一字段 + } + if _, ok := have[f]; !ok { + t.Errorf("%s 协议需要字段 %q,但统一格式没有它(适配器将被迫走旁路)", proto, f) + } + } + } + + // 反向:确认确实带了内容,不是空对象 + if len(have) < 15 { + t.Errorf("统一请求只序列化出 %d 个字段,最大集合应远多于此", len(have)) + } +} + +// TestNoKernelPrivateFieldLeak 是 v1 那个具体 bug 的回归测试。 +// +// v1:disable_thinking 是内核私有开关,却被序列化进交给适配器的 body, +// 于是 10 个适配器各自写下 `req.disable_thinking = nil` 来清理它。 +// v2:该语义改名为 thinking(一个**双方都认可**的字段),且没有内核私有 +// 字段混进 body。 +func TestNoKernelPrivateFieldLeak(t *testing.T) { + raw, err := json.Marshal(UnifiedRequest{ + Model: "m", + Messages: []ChatMessage{{Role: "user", Content: StringContent("hi")}}, + Thinking: &ThinkingConfig{Enabled: b(false)}, + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + if _, leaked := have["disable_thinking"]; leaked { + t.Error("内核私有字段 disable_thinking 泄漏进了交给适配器的 body(v1 回归)") + } + // thinking 必须是**客户端语义**,独立于任何具体协议的关闭方式 + if _, ok := have["thinking"]; !ok { + t.Error("thinking 语义字段缺失;适配器将无法得知客户端要求关闭思考") + } + // ClientSession 绝不能进 body:它只用于派生上游会话标识 + if _, leaked := have["client_session"]; leaked { + t.Error("网关内部字段 client_session 泄漏进上游 body") + } +} + +// TestNoProtocolContainerLeak 确认超集只带**语义**,不带协议容器形状。 +// +// Gemini 的 generationConfig、Ollama 的 options 都是"上游协议的形状", +// 属于适配器内部事务。若它们出现在统一格式里,说明内核又开始懂协议了。 +func TestNoProtocolContainerLeak(t *testing.T) { + raw, err := json.Marshal(UnifiedRequest{ + Model: "m", + Messages: []ChatMessage{{Role: "user", Content: StringContent("hi")}}, + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + forbidden := []string{ + "generationConfig", // Gemini 容器 + "systemInstruction", // Gemini 容器 + "options", // Ollama 容器 + "contents", // Gemini 的消息数组形状 + "num_predict", // Ollama 的参数名 + "maxOutputTokens", // Gemini 的参数名 + "thinkingConfig", // Gemini 的推理容器 + } + for _, f := range forbidden { + if _, bad := have[f]; bad { + t.Errorf("统一格式出现了协议专属字段/容器 %q —— 内核不应携带任何协议形状", f) + } + } +} + +// TestUnifiedResultCoversProtocols 核对响应侧的超集覆盖。 +func TestUnifiedResultCoversProtocols(t *testing.T) { + raw, err := json.Marshal(UnifiedResult{ + Model: "m", + Content: "hi", + Thinking: "let me think", + FinishReason: "stop", + TokenUsage: TokenUsage{Prompt: 1, Completion: 2, Total: 3}, + ToolCalls: []ToolCall{{ID: "1", Name: "f"}}, + Citations: []json.RawMessage{json.RawMessage(`{"src":"x"}`)}, + Refusal: "", + SafetyRatings: []json.RawMessage{json.RawMessage(`{"category":"HARM"}`)}, + Cost: "0.0012", + }) + if err != nil { + t.Fatal(err) + } + var have map[string]json.RawMessage + if err := json.Unmarshal(raw, &have); err != nil { + t.Fatal(err) + } + for _, f := range []string{"content", "thinking", "finish_reason", + "token_usage", "tool_calls", "citations", "safety_ratings", "cost"} { + if _, ok := have[f]; !ok { + t.Errorf("统一响应缺少 %q,该协议的语义将无法表达", f) + } + } +} + +// TestFinishReasonVocabularyDocumentsNormalization 记录归一化词表。 +// +// 适配器必须把上游措辞映射到这套词表;内核与对外 serializer 只认这套。 +func TestFinishReasonVocabularyDocumentsNormalization(t *testing.T) { + // 这不是行为测试,而是把词表钉在代码里,防止有人往这加厂商专属值。 + // 上游措辞 → 统一值的映射责任在适配器。 + canonical := map[string][]string{ + "stop": {"Anthropic end_turn", "Gemini STOP", "OpenAI stop"}, + "length": {"Anthropic max_tokens", "Gemini MAX_TOKENS", "OpenAI length"}, + "tool_calls": {"Anthropic tool_use", "Gemini FUNCTION_CALL", "OpenAI tool_calls"}, + "content_filter": {"OpenAI content_filter", "Gemini SAFETY"}, + } + for want, sources := range canonical { + if want == "" { + t.Fatal("unreachable") + } + // 词表本身是文档;这里只断言它非空且来源明确 + if len(sources) == 0 { + t.Errorf("finish_reason %q 没有记录任何来源协议措辞", want) + } + } +} + +// ---- 测试辅助 ---- +func f64(v float64) *float64 { return &v } +func i64(v int64) *int64 { return &v } +func b(v bool) *bool { return &v }