- 新增 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 口径冲突,停手报告)
211 lines
8.1 KiB
Go
211 lines
8.1 KiB
Go
package model
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// discardLogger returns a logger that drops every record but still carries the
|
|
// adapter's module source, so an adapter built without an injected logger can
|
|
// never emit an unsourced record.
|
|
func discardLogger(source string) *slog.Logger {
|
|
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source)
|
|
}
|
|
|
|
// Reasoning wire values shared by the OpenAI-compatible and Anthropic adapters
|
|
// (design/04 §3.2, checked against the provider documents on 2026-10-03).
|
|
const (
|
|
// thinkingTypeEnabled and thinkingTypeDisabled are the values of the
|
|
// {"thinking":{"type":...}} switch. DeepSeek's default is enabled, so omitting
|
|
// the field leaves thinking ON — "do not intervene" is not a way to switch it
|
|
// off; only an explicit disabled (or reasoning_effort "none") is.
|
|
thinkingTypeEnabled = "enabled"
|
|
thinkingTypeDisabled = "disabled"
|
|
// effortNone is the reasoning_effort value that switches thinking off. It is a
|
|
// switch, not a strength tier, so it must never be replaced by a tier: mapping
|
|
// it onto "low" would invert the caller's intent (thinking off → thinking on).
|
|
effortNone = "none"
|
|
)
|
|
|
|
// reasoningVendor identifies the vendor behind an OpenAI-compatible endpoint.
|
|
// The wire protocol is the same for all of them, but the accepted
|
|
// reasoning_effort values are not, so a value must be checked against the right
|
|
// whitelist instead of being forwarded blindly.
|
|
type reasoningVendor string
|
|
|
|
const (
|
|
vendorOpenAI reasoningVendor = "openai"
|
|
vendorDeepSeek reasoningVendor = "deepseek"
|
|
vendorQwen reasoningVendor = "qwen"
|
|
vendorUnknown reasoningVendor = "unknown"
|
|
)
|
|
|
|
// reasoningEffortAliases is DeepSeek's documented compatibility mapping: the
|
|
// service accepts these aliases and applies the mapped tier. The mapping is
|
|
// applied on our side too, so the request body always carries the tier the
|
|
// provider documents as the effective one (design/04 §3.2 fact 1).
|
|
var reasoningEffortAliases = map[string]string{
|
|
"minimal": "low",
|
|
"medium": "high",
|
|
"xhigh": "high",
|
|
"ultra": "max",
|
|
}
|
|
|
|
// reasoningEffortAllowed is the per-vendor set of accepted reasoning_effort
|
|
// values. A vendor absent from this table has no reasoning_effort support at all,
|
|
// which is reported rather than guessed.
|
|
//
|
|
// There is deliberately no "round down to the nearest tier" fallback: an
|
|
// unsupported value is dropped and reported instead, because silently changing it
|
|
// can invert the caller's intent — the original bug mapped DeepSeek's "none"
|
|
// (thinking off) onto "low" (thinking on) exactly that way.
|
|
var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{
|
|
vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"),
|
|
vendorDeepSeek: tierSet("none", "low", "high", "max"),
|
|
}
|
|
|
|
// tierSet builds a membership set from the given tiers.
|
|
func tierSet(tiers ...string) map[string]bool {
|
|
set := make(map[string]bool, len(tiers))
|
|
for _, t := range tiers {
|
|
set[t] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
// reasoningVendorFor classifies the vendor of an OpenAI-compatible request from
|
|
// its model name, falling back to the base URL when the name does not identify
|
|
// one. An unrecognized pair yields vendorUnknown so the caller reports "not
|
|
// supported" instead of assuming OpenAI semantics. The Anthropic adapter uses the
|
|
// same classification to tell a DeepSeek /anthropic endpoint (which ignores
|
|
// budget_tokens) from the Anthropic endpoint.
|
|
func reasoningVendorFor(model, baseURL string) reasoningVendor {
|
|
m := strings.ToLower(strings.TrimSpace(model))
|
|
switch {
|
|
case strings.Contains(m, "deepseek"):
|
|
return vendorDeepSeek
|
|
case strings.Contains(m, "qwen"):
|
|
return vendorQwen
|
|
case strings.Contains(m, "gpt-"), strings.Contains(m, "chatgpt"),
|
|
strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), strings.HasPrefix(m, "o4"):
|
|
return vendorOpenAI
|
|
}
|
|
u := strings.ToLower(strings.TrimSpace(baseURL))
|
|
switch {
|
|
case strings.Contains(u, "deepseek"):
|
|
return vendorDeepSeek
|
|
case strings.Contains(u, "dashscope"), strings.Contains(u, "aliyuncs"):
|
|
return vendorQwen
|
|
case strings.Contains(u, "openai"):
|
|
return vendorOpenAI
|
|
}
|
|
return vendorUnknown
|
|
}
|
|
|
|
// thinkingSwitchValue maps an explicit on/off switch onto its wire value.
|
|
func thinkingSwitchValue(enable bool) string {
|
|
if enable {
|
|
return thinkingTypeEnabled
|
|
}
|
|
return thinkingTypeDisabled
|
|
}
|
|
|
|
// normalizeDeepSeekEffort maps an effort onto the tier DeepSeek applies and
|
|
// reports whether the value is one the service documents: the documented set
|
|
// (none/low/high/max) passes through, the compatibility aliases map to their
|
|
// official tier.
|
|
func normalizeDeepSeekEffort(effort string) (string, bool) {
|
|
if tier, ok := reasoningEffortAliases[effort]; ok {
|
|
return tier, true
|
|
}
|
|
if reasoningEffortAllowed[vendorDeepSeek][effort] {
|
|
return effort, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// resolveReasoningEffort validates effort against the vendor's whitelist and
|
|
// returns the value to send; "" drops the field. An unrecognized value is never
|
|
// 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 := reasoningEffortAllowed[vendor]
|
|
if allowed[effort] {
|
|
return effort
|
|
}
|
|
if vendor == vendorDeepSeek {
|
|
if tier, ok := normalizeDeepSeekEffort(effort); ok {
|
|
log.Warn("reasoning_effort alias normalized to the tier DeepSeek applies",
|
|
"provider", "deepseek", "field", "reasoning_effort",
|
|
"value", effort, "sent", tier, "model", model)
|
|
return tier
|
|
}
|
|
}
|
|
log.Warn("reasoning_effort dropped: unrecognized value",
|
|
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
|
|
return ""
|
|
}
|
|
|
|
// 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 {
|
|
switch v := toolChoice.(type) {
|
|
case nil:
|
|
return false
|
|
case string:
|
|
return strings.EqualFold(strings.TrimSpace(v), "required")
|
|
case map[string]any:
|
|
if forcedToolChoiceType(v["type"]) {
|
|
return true
|
|
}
|
|
_, named := v["function"]
|
|
return named
|
|
case map[string]string:
|
|
if forcedToolChoiceType(v["type"]) {
|
|
return true
|
|
}
|
|
_, named := v["function"]
|
|
return named
|
|
}
|
|
return false
|
|
}
|
|
|
|
// forcedToolChoiceType reports whether a tool_choice "type" value forces a call.
|
|
func forcedToolChoiceType(value any) bool {
|
|
t, _ := value.(string)
|
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
|
case "required", "any", "tool":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// reportForcedToolChoiceWithThinking warns about the DeepSeek red line that a
|
|
// forced tool call — tool_choice "required"/"any"/a named tool — and thinking are
|
|
// not compatible: while thinking is on (its default) the API rejects the forcing
|
|
// directive with a 400, so the caller must switch thinking off explicitly first
|
|
// (design/04 §3.2 fact 5). It only reports: resolution must never switch thinking
|
|
// 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, provider, model string, toolChoice any, thinkingOn bool) {
|
|
if !thinkingOn || !toolChoiceForces(toolChoice) {
|
|
return
|
|
}
|
|
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)
|
|
}
|