Files
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

268 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}