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 }