Files
Cyrene_v2/internal/runtime/model/openai_encode.go
T
AskaEth 8890c30bb5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
  · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
  · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
  · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
  · 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
  每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
  否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
  (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
  (design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00

247 lines
8.6 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"`
// Thinking is DeepSeek's top-level thinking switch. It is sent only for
// DeepSeek and only from an explicit switch (or an explicit off that must be
// honoured): leaving it out keeps the provider default, which is ENABLED, so
// an omitted field is not a way to switch thinking off (design/04 §3.2
// fact 2).
Thinking *openAIThinking `json:"thinking,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 unrecognized value is dropped, never turned into a different tier.
ReasoningEffort string `json:"reasoning_effort,omitempty"`
// ChatTemplateKwargs carries the vLLM-style thinking switch
// (chat_template_kwargs.enable_thinking, design/04 §3.2 Qwen3 行). It is sent
// only to an endpoint whose provider declared reasoning.kind=vllm.
ChatTemplateKwargs *openAIChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
// EnableThinking carries the DashScope compatible-mode thinking switch (the
// top-level enable_thinking). It is sent only to an endpoint whose provider
// declared reasoning.kind=dashscope — the shape is never inferred, because a
// wrong shape 400s the request.
EnableThinking *bool `json:"enable_thinking,omitempty"`
}
// openAIThinking is the wire form of DeepSeek's thinking switch: enabled or
// disabled. It is only ever sent to a DeepSeek endpoint.
type openAIThinking struct {
Type string `json:"type"`
}
// openAIChatTemplateKwargs is the wire form of the vLLM (Qwen3) thinking switch:
// the boolean lives nested under chat_template_kwargs.
type openAIChatTemplateKwargs struct {
EnableThinking *bool `json:"enable_thinking,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 and whether it has a thinking switch;
// classify it from the resolved model (and the base URL as a fallback) before
// touching the request. The endpoint's configured shape (models.json
// reasoning.kind, 裁决 03.4.2/08.4.1) wins over that classification, because
// the same wire protocol carries different switch shapes (vLLM vs 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 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
}