Files
Cyrene_v2/internal/runtime/model/model.go
T
AskaEth 0de68bc960
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(model): DeepSeek 推理参数语义反转 + thinking 开关真发(按官方文档核对结果)
依据 DevDocs/design/04 §3.2「官方核对后的修正」9 条 + 本地 DevDocs/reference/deepseek/facts-for-cyrene.md(官方原句/URL)。
- 🔴 reasoning_effort 白名单改为官方集合 [none, low, high, max] + 别名映射(minimal→low, medium→high, xhigh→high, ultra→max);**删除就近降级**,非法值一律丢弃+WARN。此前 none(关思考) 被降级成 low(开思考) 属语义反转
- 🔴 DeepSeek thinking 开关真发顶层 {"thinking":{"type":"enabled|disabled"}}(官方默认 enabled,不传≠关);此前只打 WARN 导致 mode:off 静默无效
- DeepSeek 的 /anthropic 端点忽略 budget_tokens → 不再对其抬 max_tokens(会误导预算/日志),改为按官方用 output_config.effort
- 注释纠正:penalty 属接口级 deprecated(非仅思考模式);top_p 仅思考模式生效且下限 0.95(不加该字段)
- tool_choice 雷登记:思考模式下 required/指定 tool 会 400(思考默认开)
- 旧模型名清理(deepseek-chat/reasoner 已于 2026-07-24 停用)
- 请求体级测试 + 变异验证(把 none 改回 low / 把 thinking 改回只 WARN → 对应断言 FAIL)
2026-10-03 20:22:31 +08:00

258 lines
11 KiB
Go

// Package model provides a vendor-agnostic LLM adapter layer for Cyrene v2.
//
// It defines a normalized Message/Request/Response vocabulary, a single
// Adapter interface implemented by OpenAIChatAdapter (which speaks the
// OpenAI-compatible /chat/completions protocol used by OpenAI, DashScope/百炼,
// local gateways and proxies), a per-role Router, an automatic fallback Chain,
// multimodal (vision) detection, and per-call CallRecord accounting.
//
// The full tool loop (schema registration, argument binding, result re-feed) is
// deferred to M2; this package only carries interface-level placeholders
// (ToolSpec) so the wire contract is ready without executing any loop.
package model
import "time"
// Role identifies who authored a message. The values are vendor-agnostic and
// translated to each provider's own role vocabulary by the adapter.
type Role string
const (
// RoleSystem is a system/instruction message.
RoleSystem Role = "system"
// RoleUser is an end-user message.
RoleUser Role = "user"
// RoleAssistant is an assistant (model) message.
RoleAssistant Role = "assistant"
// RoleTool is a tool-result message (reserved for the M2 tool loop).
RoleTool Role = "tool"
)
// ContentType describes the kind of payload inside a Content part.
type ContentType string
const (
// ContentTypeText is a plain-text content part.
ContentTypeText ContentType = "text"
// ContentTypeImage is an image content part.
ContentTypeImage ContentType = "image_url"
// ContentTypeToolCall is an assistant-originated tool invocation part
// (Anthropic tool_use / OpenAI tool_calls), carried on a RoleAssistant turn.
ContentTypeToolCall ContentType = "tool_call"
// ContentTypeToolResult is a tool execution result part, carried on a RoleTool
// turn and encoded natively (Anthropic tool_result / OpenAI tool message).
ContentTypeToolResult ContentType = "tool_result"
)
// Content is a single, possibly multimodal, part of a message.
type Content struct {
// Type distinguishes a text part from an image / tool part.
Type ContentType
// Text is the text payload when Type == ContentTypeText, or the result body
// when Type == ContentTypeToolResult.
Text string
// ImageURL is the image source when Type == ContentTypeImage. It may be a
// data: URI or an http(s):// URL.
ImageURL string
// ToolCallID correlates the part to a provider tool_use/tool_call id: the call
// id for ContentTypeToolCall, or the tool_use_id being answered for
// ContentTypeToolResult.
ToolCallID string
// ToolCallName is the invoked tool name when Type == ContentTypeToolCall.
ToolCallName string
// ToolArgs is the structured argument object when Type == ContentTypeToolCall.
// It is never a JSON-encoded string.
ToolArgs map[string]any
// IsError marks a ContentTypeToolResult part as an execution failure so the
// provider's tool_result.is_error / error flag is set natively.
IsError bool
}
// Message is a single conversation turn, normalized across providers.
type Message struct {
Role Role
// Content carries one or more parts. A text-only message uses a single
// ContentTypeText part.
Content []Content
// SenderName is the human-readable display name of the message's sender
// (design/16 §4.6). It is transport metadata used by the context-assembly
// layer to label third-party messages for the model (e.g. "[第三方 · 昵称]"),
// never a substitute for the strongly typed sender identity carried on the
// channel layer. Empty means the sender is unlabeled (the owner, or an
// unknown/unresolved sender), and callers may leave it unset.
SenderName string
// ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id
// it answers, and ToolCallName is the invoked tool name carried as a
// convenience for callers that render the result. Both are set by the tool
// loop when it feeds a result back, and consumed by the adapters to emit
// native tool_result / tool-message blocks.
ToolCallID string
ToolCallName string
}
// TextMessage builds a text-only message.
func TextMessage(role Role, text string) Message {
return Message{Role: role, Content: []Content{{Type: ContentTypeText, Text: text}}}
}
// ImageMessage builds a message carrying optional text plus an image. imageURL
// may be a data: URI or an http(s):// URL. An empty text omits the text part.
func ImageMessage(role Role, text, imageURL string) Message {
parts := make([]Content, 0, 2)
if text != "" {
parts = append(parts, Content{Type: ContentTypeText, Text: text})
}
parts = append(parts, Content{Type: ContentTypeImage, ImageURL: imageURL})
return Message{Role: role, Content: parts}
}
// ToolResultMessage builds a RoleTool turn carrying the native result of one tool
// call. toolUseID must equal the provider id of the tool_use block this result
// answers; isError marks an execution failure; text is the result body.
func ToolResultMessage(toolUseID, text string, isError bool) Message {
return Message{Role: RoleTool, ToolCallID: toolUseID,
Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}}
}
// AssistantToolCallMessage builds a RoleAssistant turn advertising the tool calls
// the model requested, so a subsequent RoleTool turn can be correlated to them by
// the provider (OpenAI tool_call_id / Anthropic tool_use_id).
func AssistantToolCallMessage(calls []ToolCall) Message {
parts := make([]Content, 0, len(calls))
for _, c := range calls {
parts = append(parts, Content{Type: ContentTypeToolCall, ToolCallID: c.ID, ToolCallName: c.Name, ToolArgs: c.Args})
}
return Message{Role: RoleAssistant, Content: parts}
}
// ToolSpec is an interface-level placeholder for a tool/function schema. The
// full tool loop (registration, argument binding, result re-feed) is M2; here
// it only lets a request advertise tools to the provider in the wire contract.
type ToolSpec struct {
Name string
Description string
// Parameters is the JSON Schema for the tool arguments. Populated from M2.
Parameters any
}
// ToolCall is a single assistant-originated tool invocation parsed from a
// provider's tool_use content block. Args is the structured argument object
// (never a serialized JSON string), so a tool loop can bind it directly without
// re-parsing.
type ToolCall struct {
// ID is the provider tool_use/tool_call id, so a tool loop can correlate the
// resulting RoleTool message to the exact call (Anthropic tool_use_id / OpenAI
// tool_call_id). Empty when the provider emitted no id.
ID string
// Name is the tool being invoked.
Name string
// Args are the tool arguments, decoded from the provider's structured
// tool_use.input object. It is never a JSON-encoded string.
Args map[string]any
}
// Request is a completion request, normalized across providers.
type Request struct {
// Model overrides the adapter's configured default model. Empty means the
// adapter default (or the chain step model) applies.
Model string
Messages []Message
// Temperature is the sampling temperature. nil means the provider default.
//
// DeepSeek notes (design/04 §3.2 facts 3 and 4): temperature takes no effect
// while thinking is on. frequency_penalty and presence_penalty are deprecated
// at the API level — "传入该参数将不会产生任何效果" — in every mode, which is
// why Request has no field for them. top_p is deliberately not exposed
// either: it only applies while thinking is on and is clamped to >= 0.95
// there, is always 1.0 otherwise, so sending a configured value would be
// silently ineffective on one side of the switch and a hard floor on the
// other.
Temperature *float64
// MaxTokens caps the generated tokens. Zero means the provider default.
MaxTokens int
// Purpose labels the call for CallRecord accounting (e.g. "chat").
Purpose string
// Tools is the interface-level placeholder for M2 function calling.
Tools []ToolSpec
// ToolChoice optionally directs how the provider selects a tool. It is an
// opaque provider-agnostic directive (e.g. {"type":"auto"} or {"type":"tool",
// "name":"x"}); empty means the provider default. Passed through verbatim to
// protocols that support it (Anthropic's tool_choice).
//
// DeepSeek red line (design/04 §3.2 fact 5): with thinking on — its default —
// a forcing directive ("required" / "any" / a named tool) is rejected with a
// 400, so thinking must be switched off explicitly first. The adapters only
// WARN about that combination; they never switch thinking off on their own.
ToolChoice any
// Thinking controls the provider's reasoning budget/switch (design/04 §3.2).
// nil means "do not intervene" and uses the provider default. A provider
// whose protocol has no mapping for it logs a WARN rather than silently
// ignoring it.
//
// "Do not intervene" is not the same as "off": DeepSeek's thinking defaults to
// enabled, so Enable=false is what closes it there (sent as
// {"thinking":{"type":"disabled"}}).
Thinking *Thinking
// ReasoningEffort is the OpenAI-compatible reasoning strength knob
// (reasoning_effort). Empty means "do not intervene". The value is validated
// against the detected provider's whitelist; an unrecognized value is dropped
// with a WARN and never replaced by a different tier (design/04 §3.2 fact 1).
// DeepSeek additionally documents compatibility aliases (minimal→low,
// medium/xhigh→high, ultra→max), which are normalized to the tier the service
// applies.
ReasoningEffort string
}
// Thinking carries the reasoning knobs a caller wants applied (design/04 §3.2).
// Providers map the fields they actually support and report the rest; it only
// controls budget/switch, never the thought content (design/01 §2.1 red line).
type Thinking struct {
// BudgetTokens is the Anthropic thinking.budget_tokens target (the official
// semantic is a target, not a hard cap). Zero selects the adapter default.
BudgetTokens int
// Enable is an explicit on/off switch for providers that expose a boolean
// thinking flag. nil = no explicit switch.
Enable *bool
}
// Response is a normalized completion result.
type Response struct {
// Model is the model that produced the response.
Model string
Content string
// Message is the normalized assistant message.
Message Message
// ToolCalls carries any tool_use blocks the assistant emitted. Empty when the
// response is pure text.
ToolCalls []ToolCall
// StopReason is the provider's raw stop/finish reason (Anthropic stop_reason:
// end_turn / max_tokens / tool_use; OpenAI finish_reason: stop / length /
// tool_calls), so callers can distinguish a token-budget truncation from a
// normal end. Empty when the provider reports none.
StopReason string
// PromptTokens, CompletionTokens and TotalTokens report provider usage when
// available; zero when the provider does not return it.
PromptTokens int
CompletionTokens int
TotalTokens int
}
// CallRecord describes a single successfully completed LLM call, for the M9
// monitor/frontend. A failed attempt emits no record.
type CallRecord struct {
// Model is the model that served the request.
Model string
// Purpose labels what the call was for.
Purpose string
// Tokens is the total token consumption reported by the provider.
Tokens int
// TookMs is the end-to-end call latency in milliseconds.
TookMs int64
}
// Options carries per-call knobs layered on top of the Request.
type Options struct {
// Timeout bounds a single HTTP call. Zero uses the adapter client timeout.
Timeout time.Duration
}