Files
AskaEth 6b2b07cbef
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): document 内容类型与持久化(design/24 S2)
- 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 负例红。
2026-10-04 17:07:24 +08:00

257 lines
9.0 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 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
}