- 新增 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 口径冲突,停手报告)
268 lines
13 KiB
Go
268 lines
13 KiB
Go
package model
|
||
|
||
import (
|
||
"log/slog"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// 本文件把调用方的推理旋钮映射到**端点自己的** wire 形状(裁决 03.4.2 / 08.4.1:
|
||
// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配,禁止「识别到配置
|
||
// 却只 WARN 不下发」的假适配)。形状由 models.json 组的 `reasoning.kind` 显式声明
|
||
// (配置载体见 internal/config/runtimeconfig),未声明时按模型名/BaseURL 推断厂商,
|
||
// 且只在形状**无歧义**时才下发。
|
||
//
|
||
// 依据(实现前逐条核对,不猜字段名):
|
||
// - DeepSeek:顶层 `thinking:{type:enabled|disabled}` + `reasoning_effort`
|
||
// (官方取值 none/low/high/max 与别名表;思考**默认开**)。来源:
|
||
// `DevDocs/reference/deepseek/guides-thinking_mode.md`(官方文档本地副本)。
|
||
// - OpenAI 系:只有 `reasoning_effort`,没有布尔思考开关。来源:design/04 §3.2。
|
||
// - vLLM(承载 Qwen3 等):`chat_template_kwargs.enable_thinking`。来源:design/04
|
||
// §3.2 的 Qwen3 行(记作 `extra_body.chat_template_kwargs.enable_thinking`;
|
||
// extra_body 是 OpenAI SDK「把键合并进顶层 body」的机制,与 DeepSeek 官方文档
|
||
// 对 `thinking` 的描述同构,故线上形状是顶层 `chat_template_kwargs`)。
|
||
// - DashScope 兼容模式:顶层 `enable_thinking`。来源:design/04 §3.2(「DashScope
|
||
// 等互不相同」)+仓库既有实现(reasoning.go 原注释)。⚠️ 本仓库没有 DashScope
|
||
// 官方文档本地副本,因此该形状**只在配置显式声明 reasoning.kind=dashscope 时
|
||
// 下发**,绝不从模型名/BaseURL 自动推断(同一模型名可能由 vLLM 或 DashScope
|
||
// 承载,形状不同,发错会 400)。
|
||
// - Anthropic:自己的 `thinking:{type,budget_tokens}`,见 anthropic_reasoning.go。
|
||
|
||
// switchShape 描述「只有布尔开关、没有 reasoning_effort」的端点形状。
|
||
type switchShape struct {
|
||
// provider 是 WARN 与日志里报告的名字(vllm / dashscope)。
|
||
provider string
|
||
// field 是该形状开关的线上字段路径,只用于日志(含配置指引)。
|
||
field string
|
||
}
|
||
|
||
var (
|
||
// shapeVLLM 是 vLLM 类部署:开关在 chat_template_kwargs 里。
|
||
shapeVLLM = switchShape{provider: "vllm", field: "chat_template_kwargs.enable_thinking"}
|
||
// shapeDashScope 是 DashScope 兼容模式:开关在请求体顶层。
|
||
shapeDashScope = switchShape{provider: "dashscope", field: "enable_thinking"}
|
||
)
|
||
|
||
// openAIReasoning 是一次调用要并入 OpenAI 兼容请求体的推理指令。它是各形状的
|
||
// 并集,但**只会填入该端点形状对应的那一个字段**:形状之间互不替代。
|
||
type openAIReasoning struct {
|
||
// thinking 是 DeepSeek 的顶层开关 {"thinking":{"type":...}}。
|
||
thinking *openAIThinking
|
||
// effort 是 reasoning_effort(OpenAI 系与 DeepSeek 用)。
|
||
effort string
|
||
// template 是 vLLM 的 {"chat_template_kwargs":{"enable_thinking":..}}。
|
||
template *openAIChatTemplateKwargs
|
||
// enable 是 DashScope 兼容模式的顶层 enable_thinking。
|
||
enable *bool
|
||
// provider 是本次解析归属的 provider 名,供日志定位。
|
||
provider string
|
||
// providerDefaultOn 报告该端点**未声明开关时**的思考默认状态。只有 DeepSeek
|
||
// 有官方依据(默认 enabled),其余形状为 false:没有依据就不断言其默认。
|
||
providerDefaultOn bool
|
||
}
|
||
|
||
// thinkingOn 报告该请求的思考是否处于开启状态(显式开关优先,其次是
|
||
// reasoning_effort none 这个官方关闭值,最后才是端点自身默认)。它只用于
|
||
// 提示性 WARN(强制 tool_choice 与思考不兼容),不改变请求。
|
||
func (r openAIReasoning) thinkingOn() bool {
|
||
switch {
|
||
case r.thinking != nil:
|
||
return r.thinking.Type == thinkingTypeEnabled
|
||
case r.template != nil && r.template.EnableThinking != nil:
|
||
return *r.template.EnableThinking
|
||
case r.enable != nil:
|
||
return *r.enable
|
||
case r.effort != "":
|
||
return r.effort != effortNone
|
||
}
|
||
return r.providerDefaultOn
|
||
}
|
||
|
||
// setSwitch 把布尔开关按形状挂到对应字段上。
|
||
func (r *openAIReasoning) setSwitch(shape switchShape, enable bool) {
|
||
value := enable
|
||
switch shape {
|
||
case shapeVLLM:
|
||
r.template = &openAIChatTemplateKwargs{EnableThinking: &value}
|
||
case shapeDashScope:
|
||
r.enable = &value
|
||
}
|
||
}
|
||
|
||
// resolveOpenAIReasoning 按 provider 形状解析推理指令。kind 是配置声明的形状
|
||
// (空/"auto" = 推断);vendor 是推断出的厂商,仅在 auto 下参与决策。
|
||
func resolveOpenAIReasoning(log *slog.Logger, kind string, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||
case runtimeconfig.ReasoningKindDeepSeek:
|
||
return resolveDeepSeekReasoning(log, model, th, effort)
|
||
case runtimeconfig.ReasoningKindOpenAI:
|
||
return resolveEffortOnlyReasoning(log, vendorOpenAI, model, th, effort)
|
||
case runtimeconfig.ReasoningKindVLLM:
|
||
return resolveSwitchReasoning(log, shapeVLLM, model, th, effort)
|
||
case runtimeconfig.ReasoningKindDashScope:
|
||
return resolveSwitchReasoning(log, shapeDashScope, model, th, effort)
|
||
case "", runtimeconfig.ReasoningKindAuto:
|
||
return resolveAutoReasoning(log, vendor, model, th, effort)
|
||
default:
|
||
// 配置里写了一个本版本不认识的形状:保守不发任何字段,但必须报告,
|
||
// 否则「配了却不生效」正是本裁决要消灭的假适配。
|
||
log.Warn("reasoning.kind unrecognized: no reasoning field is sent",
|
||
"kind", kind, "model", model, "field", "reasoning.kind")
|
||
return openAIReasoning{provider: strings.TrimSpace(kind)}
|
||
}
|
||
}
|
||
|
||
// resolveAutoReasoning 处理未声明形状的端点:按推断出的厂商选择**该厂商唯一
|
||
// 无歧义的**形状,厂商认识但形状有歧义(Qwen 既可能是 vLLM 也可能是 DashScope)
|
||
// 时不下发开关,只报告并给出配置指引。
|
||
func resolveAutoReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||
switch vendor {
|
||
case vendorDeepSeek:
|
||
return resolveDeepSeekReasoning(log, model, th, effort)
|
||
case vendorOpenAI:
|
||
return resolveEffortOnlyReasoning(log, vendorOpenAI, model, th, effort)
|
||
default:
|
||
return resolveNoShapeReasoning(log, vendor, model, th, effort)
|
||
}
|
||
}
|
||
|
||
// resolveDeepSeekReasoning 映射 DeepSeek 的 OpenAI 兼容形状:顶层 thinking 开关
|
||
// 与 reasoning_effort。DeepSeek 的思考**默认开启**,所以「不传」不等于「关」;
|
||
// 要关只能显式 disabled 或 effort none(官方文档 guides-thinking_mode)。
|
||
func resolveDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) openAIReasoning {
|
||
effort = resolveReasoningEffort(log, vendorDeepSeek, effort, model)
|
||
out := openAIReasoning{effort: effort, provider: string(vendorDeepSeek), providerDefaultOn: true}
|
||
if th != nil && th.BudgetTokens > 0 {
|
||
log.Warn("thinking.budget_tokens ignored: the DeepSeek chat-completions protocol takes reasoning_effort, not a token budget",
|
||
"provider", string(vendorDeepSeek), "field", "thinking.budget_tokens",
|
||
"budget_tokens", th.BudgetTokens, "model", model)
|
||
}
|
||
if th == nil || th.Enable == nil {
|
||
return out
|
||
}
|
||
if !*th.Enable {
|
||
if effort != "" {
|
||
log.Warn("reasoning_effort dropped: thinking is explicitly disabled",
|
||
"provider", string(vendorDeepSeek), "field", "reasoning_effort", "value", effort, "model", model)
|
||
}
|
||
out.thinking = &openAIThinking{Type: thinkingTypeDisabled}
|
||
out.effort = ""
|
||
return out
|
||
}
|
||
if effort == effortNone {
|
||
// 「关」胜过「开」:provider 未声明优先级,让强度档把思考重新打开正是
|
||
// 本解析要阻止的语义反转(design/04 §3.2 fact 2)。
|
||
log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins",
|
||
"provider", string(vendorDeepSeek), "field", "reasoning_effort", "value", effort, "model", model)
|
||
out.thinking = &openAIThinking{Type: thinkingTypeDisabled}
|
||
out.effort = ""
|
||
return out
|
||
}
|
||
out.thinking = &openAIThinking{Type: thinkingTypeEnabled}
|
||
return out
|
||
}
|
||
|
||
// resolveEffortOnlyReasoning 映射「只有 reasoning_effort」的端点(OpenAI 系):
|
||
// 强度档按该厂商白名单校验,布尔开关没有 wire 字段可发,因此只报告并说明
|
||
// 官方替代路径(effort none 才是关闭推理的方式)。
|
||
func resolveEffortOnlyReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||
effort = resolveReasoningEffort(log, vendor, effort, model)
|
||
out := openAIReasoning{effort: effort, provider: string(vendor)}
|
||
if th == nil {
|
||
return out
|
||
}
|
||
if th.Enable != nil {
|
||
log.Warn("thinking switch ignored: this endpoint has no boolean thinking switch; reasoning_effort none is the documented way to disable reasoning",
|
||
"provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model)
|
||
}
|
||
if th.BudgetTokens > 0 {
|
||
log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget",
|
||
"provider", string(vendor), "field", "thinking.budget_tokens",
|
||
"budget_tokens", th.BudgetTokens, "model", model)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// resolveSwitchReasoning 映射「只有布尔开关」的端点(vLLM / DashScope 类):
|
||
// 开关按形状下发;该形状没有 reasoning_effort 字段,因此强度档被丢弃并报告——
|
||
// 唯一的例外是官方关闭值 `none`,它被翻译成**显式关思考**(红线:none = 关思考,
|
||
// 绝不翻译成任何强度档,那会把「关」变成「开」)。
|
||
func resolveSwitchReasoning(log *slog.Logger, shape switchShape, model string, th *Thinking, effort string) openAIReasoning {
|
||
out := openAIReasoning{provider: shape.provider}
|
||
var enable *bool
|
||
if th != nil {
|
||
if th.BudgetTokens > 0 {
|
||
log.Warn("thinking budget ignored: this endpoint takes a boolean thinking switch, not a token budget",
|
||
"provider", shape.provider, "field", "thinking.budget_tokens",
|
||
"budget_tokens", th.BudgetTokens, "model", model, "endpoint_field", shape.field)
|
||
}
|
||
enable = th.Enable
|
||
}
|
||
if effort == effortNone {
|
||
if enable != nil && *enable {
|
||
log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins",
|
||
"provider", shape.provider, "field", "reasoning_effort", "value", effort, "model", model)
|
||
} else if enable == nil {
|
||
log.Warn("reasoning_effort has no field on this endpoint: none is applied as the explicit thinking switch off",
|
||
"provider", shape.provider, "field", shape.field, "value", effort, "model", model)
|
||
}
|
||
out.setSwitch(shape, false)
|
||
return out
|
||
}
|
||
if effort != "" {
|
||
dropUnsupportedEffort(log, shape, model, effort)
|
||
}
|
||
if enable != nil {
|
||
out.setSwitch(shape, *enable)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// dropUnsupportedEffort 报告该形状没有 reasoning_effort 字段(含配置指引),
|
||
// 绝不改写成近似档位:改档会反转调用方意图,正是旧实现踩过的坑。
|
||
func dropUnsupportedEffort(log *slog.Logger, shape switchShape, model, effort string) {
|
||
log.Warn("reasoning_effort dropped: this endpoint has no reasoning_effort field",
|
||
"provider", shape.provider, "field", "reasoning_effort", "value", effort,
|
||
"model", model, "endpoint_field", shape.field)
|
||
}
|
||
|
||
// resolveNoShapeReasoning 处理「厂商未知或形状有歧义」的端点:不发送任何推理
|
||
// 字段(发错形状会 400),但逐项报告被忽略的旋钮与配置指引(design/04 §3.2:
|
||
// 禁止静默无效)。
|
||
func resolveNoShapeReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) openAIReasoning {
|
||
reportUnmappedThinking(log, vendor, model, th)
|
||
if effort != "" {
|
||
log.Warn("reasoning_effort ignored: provider does not support it",
|
||
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
|
||
}
|
||
return openAIReasoning{provider: string(vendor)}
|
||
}
|
||
|
||
// reportUnmappedThinking 报告一个 Thinking 指令为何无法由该端点承载:厂商认识
|
||
// 但形状有歧义(Qwen 既可能由 vLLM 承载、也可能由 DashScope 承载,两者字段形状
|
||
// 不同),或厂商未知。两条路都给出配置指引,让主人能显式声明形状而不是让我们猜。
|
||
// DeepSeek 的开关由 resolveDeepSeekReasoning 映射,不经过这里。
|
||
func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking) {
|
||
if th == nil {
|
||
return
|
||
}
|
||
if th.Enable != nil {
|
||
switch vendor {
|
||
case vendorQwen:
|
||
log.Warn("enable_thinking not forwarded: the endpoint's switch shape is ambiguous (vLLM wants chat_template_kwargs.enable_thinking, DashScope wants a top-level enable_thinking) and sending the wrong shape can 400; declare reasoning.kind to choose one",
|
||
"provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking",
|
||
"enable", *th.Enable, "model", model, "config_field", "reasoning.kind")
|
||
default:
|
||
log.Warn("thinking switch ignored: provider has no boolean thinking switch",
|
||
"provider", string(vendor), "field", "thinking.enable", "enable", *th.Enable, "model", model)
|
||
}
|
||
return
|
||
}
|
||
if th.BudgetTokens > 0 {
|
||
log.Warn("thinking budget ignored: OpenAI-compatible providers take reasoning_effort, not a token budget",
|
||
"provider", string(vendor), "field", "thinking.budget_tokens",
|
||
"budget_tokens", th.BudgetTokens, "model", model)
|
||
}
|
||
}
|