Files
Cyrene_v2/internal/runtime/model/openai_encode.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

247 lines
8.4 KiB
Go
Raw 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 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
}
func buildMessages(msgs []Message) ([]openAIMessage, error) {
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
}
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
}