- 新增 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 口径冲突,停手报告)
273 lines
12 KiB
Go
273 lines
12 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
|
|
// PersonaID is the persona this turn answers as — the persona-level routing
|
|
// key of 裁决 03.4.1, as opposed to the LLM message role. RuntimeLLM reads it
|
|
// to pick the persona's provider chain (models.json personaProviders, i.e. a
|
|
// cross-protocol per-persona route). Empty means "no persona binding": the
|
|
// active group's default chain serves the request, so every pre-existing
|
|
// caller behaves exactly as before.
|
|
//
|
|
// Why a request field and not a context key: the decision happens at the
|
|
// adapter boundary that already carries the Request, and the value is
|
|
// per-call metadata of the same kind as Model/Purpose. The persona lives in
|
|
// the loop's private session-env context key, which the model package cannot
|
|
// read (model must not import loop), so a ctx carrier would have needed the
|
|
// same write anyway — with the decision hidden behind an invisible key and
|
|
// hard to assert across packages.
|
|
PersonaID 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
|
|
}
|