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

+9 -90
View File
@@ -132,17 +132,17 @@ func normalizeDeepSeekEffort(effort string) (string, bool) {
// turned into a different tier — it is dropped and reported with provider, field,
// value and model, because changing it can invert the caller's intent. A DeepSeek
// compatibility alias is normalized to the tier the service applies and reported.
//
// vendor must be a vendor with a reasoning_effort whitelist (DeepSeek / OpenAI):
// the shapes that carry no such field (vLLM / DashScope) and the endpoints whose
// shape is unknown drop the effort themselves and report it, so their callers
// never reach this function (see reasoning_shape.go, 裁决 03.4.2).
func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string {
effort = strings.TrimSpace(effort)
if effort == "" {
return ""
}
allowed, ok := reasoningEffortAllowed[vendor]
if !ok {
log.Warn("reasoning_effort ignored: provider does not support it",
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
return ""
}
allowed := reasoningEffortAllowed[vendor]
if allowed[effort] {
return effort
}
@@ -159,57 +159,6 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo
return ""
}
// resolveOpenAIReasoning maps the caller's reasoning knobs onto the DeepSeek
// OpenAI-compatible wire fields and returns the top-level thinking switch (nil =
// omitted) together with the reasoning_effort to send ("" = omitted). A
// non-DeepSeek vendor has no portable field for the switch, so its directive is
// only reported.
//
// When both knobs are given and disagree, the "off" side wins and the conflict is
// reported: the provider documents no precedence, and letting a strength tier
// re-open thinking is exactly the inversion this resolution exists to prevent
// (design/04 §3.2 fact 2).
func resolveOpenAIReasoning(log *slog.Logger, vendor reasoningVendor, model string, th *Thinking, effort string) (*openAIThinking, string) {
if vendor != vendorDeepSeek {
reportUnmappedThinking(log, vendor, model, th)
return nil, effort
}
var block *openAIThinking
if th != nil {
if th.BudgetTokens > 0 {
log.Warn("thinking.budget_tokens ignored: the DeepSeek chat-completions protocol takes reasoning_effort, not a token budget",
"provider", "deepseek", "field", "thinking.budget_tokens",
"budget_tokens", th.BudgetTokens, "model", model)
}
if th.Enable != nil {
block = &openAIThinking{Type: thinkingSwitchValue(*th.Enable)}
}
}
if block != nil && block.Type == thinkingTypeDisabled {
if effort != "" {
log.Warn("reasoning_effort dropped: thinking is explicitly disabled",
"provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model)
}
return block, ""
}
if effort == effortNone && block != nil {
log.Warn("reasoning_effort none contradicts thinking enabled: thinking disabled wins",
"provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model)
return &openAIThinking{Type: thinkingTypeDisabled}, ""
}
return block, effort
}
// openAIThinkingOn reports the effective thinking state of a resolved DeepSeek
// OpenAI-compatible request: an omitted switch leaves the provider default in
// place, and DeepSeek's default is enabled.
func openAIThinkingOn(th *openAIThinking, effort string) bool {
if th != nil {
return th.Type == thinkingTypeEnabled
}
return effort != effortNone
}
// toolChoiceForces reports whether a tool_choice directive obliges the model to
// call a tool ("required", Anthropic's "any", or a named tool). auto/none do not.
func toolChoiceForces(toolChoice any) bool {
@@ -252,40 +201,10 @@ func forcedToolChoiceType(value any) bool {
// off on its own. On the OpenAI-compatible adapter the directive is not on the
// wire yet (M2), so the warning is prospective there — it still records that the
// requested combination can never be honoured.
func reportForcedToolChoiceWithThinking(log *slog.Logger, model string, toolChoice any, thinkingOn bool) {
func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) {
if !thinkingOn || !toolChoiceForces(toolChoice) {
return
}
log.Warn("tool_choice forcing cannot be combined with DeepSeek thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call",
"provider", "deepseek", "field", "tool_choice", "model", model)
}
// reportUnmappedThinking logs why a Thinking directive cannot be carried by a
// non-DeepSeek OpenAI-compatible request, naming the provider and the field that
// was ignored. Qwen3 does have enable_thinking, but the protocol has no portable
// field for it (vLLM wants chat_template_kwargs.enable_thinking, DashScope's
// compatible mode wants a top-level enable_thinking), and sending the wrong shape
// can 400 the request — so this version reports it instead of guessing. DeepSeek's
// switch is mapped by resolveOpenAIReasoning, not here.
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 OpenAI-compatible protocol has no portable field for it",
"provider", "qwen", "field", "extra_body.chat_template_kwargs.enable_thinking",
"enable", *th.Enable, "model", model)
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)
}
log.Warn("tool_choice forcing cannot be combined with thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call",
"provider", provider, "field", "tool_choice", "model", model)
}