feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 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 口径冲突,停手报告)
This commit is contained in:
AskaEth committed 2026-10-04 12:57:55 +08:00
1 parent 71fefe5fc3
commit 8890c30bb5
17 files changed
+1496 -134

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
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)
}
}