按 DevDocs/design/01 §2.1/§2.2 与 design/04 §3.2 实现(规格含联网核过的官方依据:思考内容刻意不角色化、不写分步思考指令、思考控制只走参数级)。
- 人格 schema 新增 thinking:budgetTokens / effort / showToUser(*bool,省略=nil=保持现行为,显式 false=只落库) / mode(default|on|off→Enable 三态);非法 mode/负预算 → ErrInvalidThinking(不静默)
- 5 个死字段接上注入:personality→values→speechStyle→catchphrase→background 拼为独立段落(带【】小标题,空值不拼);**全空时输出与旧实现逐字节一致**(有专门测试)
- model.Request 新增 Thinking{ BudgetTokens, Enable } 与 ReasoningEffort;新增 model/reasoning.go:厂商识别(模型名→baseURL 兜底)、取值白名单(OpenAI 系 none…max / DeepSeek low|high|max)、非法值降级+WARN、**不支持一律 WARN 而非静默**;Anthropic 映射 thinking{budget_tokens} 并**保证 budget<max_tokens**(不足时抬高 max_tokens+WARN,正是历史上思考吃光预算→空回复那个坑)
- loop:新增 Options.ThinkingShowToUser(*bool) 仅门控 thinking_* 帧(正文/工具帧不受影响);场景级 mute 语义不变
- 补一处任务清单外但必需的接线:persona→model.Request 的 thinking 映射(否则 budget/effort/mode 会变成新的死字段);showToUser 刻意不产生 Thinking(纯呈现开关)
- 管理端:personaUpsertRequest 补 thinking DTO + 校验(400);PUT 省略时**保留已存块**(防旧客户端抹配置);写侧 yaml round-trip 已验证
- Docs/api-client v0.40→v0.41(可选请求字段)
- 测试:26 个新用例(loader 解析/拼接逐字节/映射、anthropic 非流式+流式预算约束、openai 透传/降级/丢弃、Qwen 诚实 WARN、loop 门控、DTO round-trip);3 次变异验证(省略与 false 混同 / 去掉预算约束 / 短路门控 各自 FAIL)
- 验收:go build/vet/test 全绿(含 57 包)+ linux/darwin 交叉编译 + 前端 build;唯一 FAIL 是既有 flake TestInterruptCancelsTurnAndClearsPending(3/20 新代码 vs 3/20 纯 HEAD,已归因非本次引入)
213 lines
6.8 KiB
Go
213 lines
6.8 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// openAIChatRequest is the JSON body sent to /chat/completions. Field names
|
|
// match the provider protocol so the struct marshals directly.
|
|
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"`
|
|
// ReasoningEffort is the OpenAI-compatible reasoning strength knob. It is set
|
|
// only after validation against the detected vendor's whitelist (design/04
|
|
// §3.2); an unsupported value is downgraded or dropped, never forwarded as-is.
|
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
|
}
|
|
|
|
// openAIStreamOpts asks the provider to include usage in the final stream chunk.
|
|
type openAIStreamOpts struct {
|
|
IncludeUsage bool `json:"include_usage"`
|
|
}
|
|
|
|
// openAIMessage is one request message in the wire body. Content is either a
|
|
// plain string (text-only) or an array of {type,text|image_url} parts. Tool
|
|
// messages carry ToolCallID (required by OpenAI); assistant messages that
|
|
// requested functions carry ToolCalls so the following tool messages can be
|
|
// correlated.
|
|
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 is the wire form of one assistant tool_call in a request. OpenAI
|
|
// requires each subsequent tool message to reference the id back to these calls.
|
|
type openAIToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function openAIToolCallFn `json:"function"`
|
|
}
|
|
|
|
// openAITool is the wire form of a function tool schema (M2 placeholder).
|
|
type openAITool struct {
|
|
Type string `json:"type"`
|
|
Function openAIToolFn `json:"function"`
|
|
}
|
|
|
|
// openAIToolFn carries the function definition.
|
|
type openAIToolFn struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Parameters any `json:"parameters,omitempty"`
|
|
}
|
|
|
|
// openAIToolCallFn is the function part of a streaming tool-call fragment.
|
|
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
|
|
}
|
|
// The vendor behind an OpenAI-compatible endpoint decides which
|
|
// reasoning_effort tiers are legal; classify it from the resolved model (and
|
|
// the base URL as a fallback) before touching the request (design/04 §3.2).
|
|
vendor := reasoningVendorFor(req.Model, a.baseURL)
|
|
reportUnmappedThinking(a.log, vendor, req.Model, req.Thinking)
|
|
wire := openAIChatRequest{
|
|
Model: req.Model,
|
|
Messages: msgs,
|
|
Temperature: req.Temperature,
|
|
MaxTokens: req.MaxTokens,
|
|
Stream: stream,
|
|
Tools: buildTools(req.Tools),
|
|
ReasoningEffort: resolveReasoningEffort(a.log, vendor, req.ReasoningEffort, req.Model),
|
|
}
|
|
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 renders one normalized message in OpenAI wire form. A
|
|
// RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI,
|
|
// and the audit's gap); an assistant turn that advertised tool calls carries the
|
|
// corresponding tool_calls array so the provider can correlate the tool messages.
|
|
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 resolves the tool_call_id for a RoleTool turn: the
|
|
// message-level id, else the id carried on the first tool-result part.
|
|
func toolMessageCallID(m Message) string {
|
|
if m.ToolCallID != "" {
|
|
return m.ToolCallID
|
|
}
|
|
for _, p := range m.Content {
|
|
if p.ToolCallID != "" {
|
|
return p.ToolCallID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// encodeToolCalls builds the assistant tool_calls array from a turn's tool-call
|
|
// parts. Arguments are serialized to JSON because OpenAI expects a string.
|
|
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-call and tool-result parts are encoded at the message level
|
|
// (tool_calls array / tool_call_id), never as text-image content.
|
|
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
|
|
}
|