- model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 + ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。 - 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest: 本期 document 永不抵达编码器,且绝不静默丢块。 - history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是 落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。 - chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO; session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。 - 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。 验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、 全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。 变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。
257 lines
9.0 KiB
Go
257 lines
9.0 KiB
Go
package model
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// openAIChatRequest 是发给 /chat/completions 的 JSON 请求体。字段名
|
||
// 与提供商协议一致,因此该结构体可以直接序列化。
|
||
type openAIChatRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []openAIMessage `json:"messages"`
|
||
Temperature *float64 `json:"temperature,omitempty"`
|
||
MaxTokens int `json:"max_tokens,omitempty"`
|
||
Stream bool `json:"stream,omitempty"`
|
||
StreamOptions *openAIStreamOpts `json:"stream_options,omitempty"`
|
||
Tools []openAITool `json:"tools,omitempty"`
|
||
// Thinking 是 DeepSeek 的顶层思考开关。它只对
|
||
// DeepSeek 发送,且只在有显式开关(或必须被
|
||
// 尊重的显式关闭)时发送:省略它会保留提供商默认值,而该默认值
|
||
// 是「开启」,所以省略字段并不是关闭思考的办法(design/04 §3.2
|
||
// 事实 2)。
|
||
Thinking *openAIThinking `json:"thinking,omitempty"`
|
||
// ReasoningEffort 是 OpenAI 兼容的推理强度旋钮。它只在
|
||
// 对已识别厂商的白名单校验通过后才设置(design/04
|
||
// §3.2);无法识别的值会被丢弃,绝不会被换成别的档位。
|
||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||
// ChatTemplateKwargs 承载 vLLM 风格的思考开关
|
||
// (chat_template_kwargs.enable_thinking,design/04 §3.2 Qwen3 行)。它只
|
||
// 发送给提供商声明了 reasoning.kind=vllm 的端点。
|
||
ChatTemplateKwargs *openAIChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
|
||
// EnableThinking 承载 DashScope 兼容模式的思考开关(
|
||
// 顶层 enable_thinking)。它只发送给提供商
|
||
// 声明了 reasoning.kind=dashscope 的端点——该形状永不推断,因为
|
||
// 形状错了会让请求 400。
|
||
EnableThinking *bool `json:"enable_thinking,omitempty"`
|
||
}
|
||
|
||
// openAIThinking 是 DeepSeek 思考开关的线格式:enabled 或
|
||
// disabled。它只会发给 DeepSeek 端点。
|
||
type openAIThinking struct {
|
||
Type string `json:"type"`
|
||
}
|
||
|
||
// openAIChatTemplateKwargs 是 vLLM(Qwen3)思考开关的线格式:
|
||
// 该布尔值嵌在 chat_template_kwargs 之下。
|
||
type openAIChatTemplateKwargs struct {
|
||
EnableThinking *bool `json:"enable_thinking,omitempty"`
|
||
}
|
||
|
||
// openAIStreamOpts 请求提供商在最后一个流块里带上用量。
|
||
type openAIStreamOpts struct {
|
||
IncludeUsage bool `json:"include_usage"`
|
||
}
|
||
|
||
// openAIMessage 是线格式请求体里的一条消息。Content 要么是
|
||
// 纯字符串(仅文本),要么是 {type,text|image_url} 片段数组。Tool
|
||
// 消息携带 ToolCallID(OpenAI 要求);请求过函数的助手消息
|
||
// 携带 ToolCalls,以便随后的 tool 消息能够
|
||
// 被关联。
|
||
type openAIMessage struct {
|
||
Role string `json:"role"`
|
||
Content any `json:"content"`
|
||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||
}
|
||
|
||
// openAIToolCall 是请求里单个助手 tool_call 的线格式。OpenAI
|
||
// 要求每条后续 tool 消息都把这些调用的 id 引用回来。
|
||
type openAIToolCall struct {
|
||
ID string `json:"id"`
|
||
Type string `json:"type"`
|
||
Function openAIToolCallFn `json:"function"`
|
||
}
|
||
|
||
// openAITool 是函数工具 schema 的线格式(M2 占位)。
|
||
type openAITool struct {
|
||
Type string `json:"type"`
|
||
Function openAIToolFn `json:"function"`
|
||
}
|
||
|
||
// openAIToolFn 承载函数定义。
|
||
type openAIToolFn struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
Parameters any `json:"parameters,omitempty"`
|
||
}
|
||
|
||
// openAIToolCallFn 是流式工具调用片段的函数部分。
|
||
type openAIToolCallFn struct {
|
||
Name string `json:"name"`
|
||
Arguments string `json:"arguments"`
|
||
}
|
||
|
||
func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, error) {
|
||
msgs, err := buildMessages(req.Messages)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// OpenAI 兼容端点背后的厂商决定哪些
|
||
// reasoning_effort 档位合法、以及它是否有思考开关;
|
||
// 在改动请求之前,先按已解析出的模型(并以 base URL 作降级)
|
||
// 分类。端点已声明的形状(models.json
|
||
// 的 reasoning.kind,裁决 03.4.2/08.4.1)优先于该分类,因为
|
||
// 同一线协议承载着不同的开关形状(vLLM 对 DashScope)。
|
||
vendor := reasoningVendorFor(req.Model, a.baseURL)
|
||
plan := resolveOpenAIReasoning(a.log, a.reasoningKind, vendor, req.Model, req.Thinking, req.ReasoningEffort)
|
||
reportForcedToolChoiceWithThinking(a.log, plan.provider, req.Model, req.ToolChoice, plan.thinkingOn())
|
||
wire := openAIChatRequest{
|
||
Model: req.Model,
|
||
Messages: msgs,
|
||
Temperature: req.Temperature,
|
||
MaxTokens: req.MaxTokens,
|
||
Stream: stream,
|
||
Tools: buildTools(req.Tools),
|
||
Thinking: plan.thinking,
|
||
ReasoningEffort: plan.effort,
|
||
ChatTemplateKwargs: plan.template,
|
||
EnableThinking: plan.enable,
|
||
}
|
||
if stream {
|
||
wire.StreamOptions = &openAIStreamOpts{IncludeUsage: true}
|
||
}
|
||
buf, err := json.Marshal(wire)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("model: %w: marshal request: %v", ErrBadRequest, err)
|
||
}
|
||
return buf, nil
|
||
}
|
||
|
||
// buildMessages 把归一化消息列表逐条渲染成 OpenAI 线格式,并在入口拒绝
|
||
// 挂在非用户回合上的文档部件(design/24 §2.3-③):文档本期永不抵达编码器,
|
||
// 漏网即编程错误,绝不静默丢块。
|
||
func buildMessages(msgs []Message) ([]openAIMessage, error) {
|
||
if err := ValidateDocumentParts(msgs); err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]openAIMessage, 0, len(msgs))
|
||
for _, m := range msgs {
|
||
wire, err := encodeOpenAIMessage(m)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, wire)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// encodeOpenAIMessage 把一条归一化消息渲染成 OpenAI 线格式。
|
||
// RoleTool 回合变成携带 tool_call_id 的 tool 消息(OpenAI 要求,
|
||
// 也是审计指出的缺口);声明过工具调用的助手回合携带
|
||
// 对应的 tool_calls 数组,好让提供商能关联 tool 消息。
|
||
func encodeOpenAIMessage(m Message) (openAIMessage, error) {
|
||
if m.Role == RoleTool {
|
||
return openAIMessage{
|
||
Role: string(RoleTool),
|
||
ToolCallID: toolMessageCallID(m),
|
||
Content: messageText(m),
|
||
}, nil
|
||
}
|
||
content, err := encodeContent(m.Content)
|
||
if err != nil {
|
||
return openAIMessage{}, err
|
||
}
|
||
return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil
|
||
}
|
||
|
||
// toolMessageCallID 解出 RoleTool 回合的 tool_call_id:
|
||
// 先是消息级 id,否则取第一个工具结果片段上的 id。
|
||
func toolMessageCallID(m Message) string {
|
||
if m.ToolCallID != "" {
|
||
return m.ToolCallID
|
||
}
|
||
for _, p := range m.Content {
|
||
if p.ToolCallID != "" {
|
||
return p.ToolCallID
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// encodeToolCalls 由回合的工具调用片段构建助手的 tool_calls
|
||
// 数组。参数序列化为 JSON,因为 OpenAI 期望字符串。
|
||
func encodeToolCalls(parts []Content) []openAIToolCall {
|
||
calls := make([]openAIToolCall, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p.Type != ContentTypeToolCall {
|
||
continue
|
||
}
|
||
arguments := "{}"
|
||
if len(p.ToolArgs) > 0 {
|
||
if b, err := json.Marshal(p.ToolArgs); err == nil {
|
||
arguments = string(b)
|
||
}
|
||
}
|
||
calls = append(calls, openAIToolCall{
|
||
ID: p.ToolCallID,
|
||
Type: "function",
|
||
Function: openAIToolCallFn{Name: p.ToolCallName, Arguments: arguments},
|
||
})
|
||
}
|
||
return calls
|
||
}
|
||
|
||
// encodeContent 把归一化内容片段转成 OpenAI 的内容字段(纯文本、或
|
||
// 文本/图片片段数组)。文档片段不走这里:本期不向提供商发任何文档块
|
||
// (design/24 §2.3-②:形状不对就是 400),因此它落到 default 分支被
|
||
// 拒绝 —— fail-loud,绝不静默丢块。
|
||
func encodeContent(parts []Content) (any, error) {
|
||
if len(parts) == 0 {
|
||
return "", nil
|
||
}
|
||
arr := make([]any, 0, len(parts))
|
||
for _, p := range parts {
|
||
switch p.Type {
|
||
case ContentTypeText:
|
||
arr = append(arr, map[string]any{"type": "text", "text": p.Text})
|
||
case ContentTypeImage:
|
||
arr = append(arr, map[string]any{
|
||
"type": "image_url",
|
||
"image_url": map[string]any{"url": p.ImageURL},
|
||
})
|
||
case ContentTypeToolCall, ContentTypeToolResult:
|
||
// 工具调用与工具结果片段在消息级编码
|
||
// (tool_calls 数组 / tool_call_id),绝不作为文本-图片内容。
|
||
continue
|
||
default:
|
||
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
|
||
}
|
||
}
|
||
if len(arr) == 0 {
|
||
return "", nil
|
||
}
|
||
if len(arr) == 1 {
|
||
if blk, ok := arr[0].(map[string]any); ok && blk["type"] == "image_url" {
|
||
return arr, nil
|
||
}
|
||
return arr[0].(map[string]any)["text"], nil
|
||
}
|
||
return arr, nil
|
||
}
|
||
|
||
func buildTools(tools []ToolSpec) []openAITool {
|
||
if len(tools) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]openAITool, 0, len(tools))
|
||
for _, t := range tools {
|
||
out = append(out, openAITool{Type: "function", Function: openAIToolFn{
|
||
Name: t.Name,
|
||
Description: t.Description,
|
||
Parameters: t.Parameters,
|
||
}})
|
||
}
|
||
return out
|
||
}
|