mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(types): 统一线格式 v2 最大集合 + 对外输出 golden 基线
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' 全绿。
This commit is contained in:
24
.snapshots/chat_nonstream.json
Normal file
24
.snapshots/chat_nonstream.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"content": "pong",
|
||||||
|
"role": "assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<ID>",
|
||||||
|
"model": "good-m",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"usage": {
|
||||||
|
"completion": 1,
|
||||||
|
"completion_tokens": 1,
|
||||||
|
"prompt": 3,
|
||||||
|
"prompt_tokens": 3,
|
||||||
|
"total": 4,
|
||||||
|
"total_tokens": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
95
.snapshots/chat_stream.txt
Normal file
95
.snapshots/chat_stream.txt
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"delta": {
|
||||||
|
"content": "",
|
||||||
|
"role": "assistant"
|
||||||
|
},
|
||||||
|
"finish_reason": null,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<ID>",
|
||||||
|
"model": "good-m",
|
||||||
|
"object": "chat.completion.chunk"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"delta": {
|
||||||
|
"content": "Hel",
|
||||||
|
"role": "assistant"
|
||||||
|
},
|
||||||
|
"finish_reason": null,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<ID>",
|
||||||
|
"model": "good-m",
|
||||||
|
"object": "chat.completion.chunk"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"delta": {
|
||||||
|
"content": "lo",
|
||||||
|
"role": "assistant"
|
||||||
|
},
|
||||||
|
"finish_reason": null,
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<ID>",
|
||||||
|
"model": "good-m",
|
||||||
|
"object": "chat.completion.chunk"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"delta": {
|
||||||
|
"content": "",
|
||||||
|
"role": "assistant"
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<ID>",
|
||||||
|
"model": "good-m",
|
||||||
|
"object": "chat.completion.chunk"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"delta": {
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<ID>",
|
||||||
|
"model": "good-m",
|
||||||
|
"object": "chat.completion.chunk"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"choices": [],
|
||||||
|
"created": <TS>,
|
||||||
|
"id": "<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]
|
||||||
8
.snapshots/image_generation.json
Normal file
8
.snapshots/image_generation.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"created": <TS>,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"b64_json": "QUJD"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
192
e2e/wire_golden_test.go
Normal file
192
e2e/wire_golden_test.go
Normal file
@ -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": "<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
|
||||||
175
internal/types/maxwire.go
Normal file
175
internal/types/maxwire.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
203
internal/types/maxwire_test.go
Normal file
203
internal/types/maxwire_test.go
Normal file
@ -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 }
|
||||||
Reference in New Issue
Block a user