Files
ModelRouter/internal/types/maxwire.go
JianFeeeee 93eeba3762 feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。

改掉的具体缺陷:

1. 私有字段泄漏归零(v1 的核心问题)
   v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
   迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
   现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
   适配器都认这个词:
   - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
     thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
   - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
   - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
     (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
   - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"

2. Tools/ToolChoice 改 json.RawMessage
   原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
   内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
   内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。

3. 删除 MarshalJSON 后门
   v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
   UnifiedRequest 上一个正正经经声明的字段。

渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。

测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00

201 lines
9.9 KiB
Go
Raw Permalink 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 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 保留 json.RawMessage(宽松的原始形状)而不是结构化的 []ToolDef,
// 原因是**渐进迁移**:内核现有的 12 个适配器与 gateway 都按 []interface{}
// /json.RawMessage 形状序列化请求到适配器。若此处改成 []ToolDef,则
// 所有适配器与调用点立即编译失败,必须同步改写 19 个非测试点 + 30 个测试。
// 而 Tools 的真实消费者是适配器(transform_request),内核本身从不解析它,
// 所以用原始形状不损失任何内核侧的统一性——它本就是"原样交给适配器的载荷"。
//
// ToolDef 类型保留在此包内供新适配器选用(maximal-set 的结构化语义),
// 但内核序列化路径暂不强推它。
Tools json.RawMessage `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
// ParallelToolCalls *bool 允许显式 false(OpenAI 语义)。
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 取代 v1 的内核私有字段 DisableThinking:那个字段曾被序列化进
// 交给适配器的 body,迫使 10 个适配器各自写 req.disable_thinking = nil。
// 这里它是双方都认可的对称语义(客户端要什么 / 上游怎么映射)。
// 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"`
}
// ChatRequest is the historical name for the in-bound request type.
//
// Deprecated: it is an alias of UnifiedRequest. The rename happened because the
// kernel's internal format stopped being "whatever the client sent" once the
// maximal set landed — it is now the maximal set, and the client's own protocol
// shape only survives until the adapter sees it.
//
// Kept as an alias (not a distinct type) so the 49 existing references keep
// compiling while call sites migrate deliberately rather than in one sweep.
type ChatRequest = UnifiedRequest