fix(model): DeepSeek 推理参数语义反转 + thinking 开关真发(按官方文档核对结果)
依据 DevDocs/design/04 §3.2「官方核对后的修正」9 条 + 本地 DevDocs/reference/deepseek/facts-for-cyrene.md(官方原句/URL)。 - 🔴 reasoning_effort 白名单改为官方集合 [none, low, high, max] + 别名映射(minimal→low, medium→high, xhigh→high, ultra→max);**删除就近降级**,非法值一律丢弃+WARN。此前 none(关思考) 被降级成 low(开思考) 属语义反转 - 🔴 DeepSeek thinking 开关真发顶层 {"thinking":{"type":"enabled|disabled"}}(官方默认 enabled,不传≠关);此前只打 WARN 导致 mode:off 静默无效 - DeepSeek 的 /anthropic 端点忽略 budget_tokens → 不再对其抬 max_tokens(会误导预算/日志),改为按官方用 output_config.effort - 注释纠正:penalty 属接口级 deprecated(非仅思考模式);top_p 仅思考模式生效且下限 0.95(不加该字段) - tool_choice 雷登记:思考模式下 required/指定 tool 会 400(思考默认开) - 旧模型名清理(deepseek-chat/reasoner 已于 2026-07-24 停用) - 请求体级测试 + 变异验证(把 none 改回 low / 把 thinking 改回只 WARN → 对应断言 FAIL)
This commit is contained in:
1 parent
0624d9ee36
commit
0de68bc960
11 files changed
+1076
-149
No files matched your search
@@ -15,20 +15,19 @@ func discardLogger(source string) *slog.Logger {
|
||||
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source)
|
||||
}
|
||||
|
||||
// Anthropic extended-thinking protocol bounds (design/04 §3.2).
|
||||
// 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 (
|
||||
// anthropicMinThinkingBudget is the smallest budget_tokens the Messages API
|
||||
// accepts for thinking.type=enabled; anything below it is rejected with a
|
||||
// 400, so a lower non-zero budget is raised and reported rather than sent.
|
||||
anthropicMinThinkingBudget = 1024
|
||||
// anthropicDefaultThinkingBudget is used when thinking is switched on without
|
||||
// an explicit budget.
|
||||
anthropicDefaultThinkingBudget = 4096
|
||||
// anthropicThinkingBodyReserve is the body room kept on top of the thinking
|
||||
// budget: max_tokens is shared by thinking and the visible answer, so the
|
||||
// budget must never be allowed to consume it all (design/04 §3.2 red line:
|
||||
// "预算与正文不得互相吃光").
|
||||
anthropicThinkingBodyReserve = 2048
|
||||
// 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.
|
||||
@@ -44,17 +43,28 @@ const (
|
||||
vendorUnknown reasoningVendor = "unknown"
|
||||
)
|
||||
|
||||
// reasoningEffortLadder is the canonical ascending order of reasoning_effort
|
||||
// tiers. It is used to round an unsupported value down to the nearest tier a
|
||||
// vendor accepts.
|
||||
var reasoningEffortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
||||
// 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 whitelist. A vendor absent from this
|
||||
// table has no reasoning_effort support at all, which is reported rather than
|
||||
// guessed.
|
||||
// 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("low", "high", "max"),
|
||||
vendorDeepSeek: tierSet("none", "low", "high", "max"),
|
||||
}
|
||||
|
||||
// tierSet builds a membership set from the given tiers.
|
||||
@@ -69,7 +79,9 @@ func tierSet(tiers ...string) map[string]bool {
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -93,11 +105,33 @@ func reasoningVendorFor(model, baseURL string) reasoningVendor {
|
||||
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. An unsupported tier is rounded down to the nearest
|
||||
// tier the vendor accepts; a value the vendor does not accept at all — or a
|
||||
// vendor with no whitelist — yields "", meaning the field is dropped. Every
|
||||
// decision is logged (provider, field, value), never silently applied.
|
||||
// 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.
|
||||
func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string {
|
||||
effort = strings.TrimSpace(effort)
|
||||
if effort == "" {
|
||||
@@ -112,83 +146,127 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo
|
||||
if allowed[effort] {
|
||||
return effort
|
||||
}
|
||||
downgraded, ok := downgradeReasoningEffort(allowed, effort)
|
||||
if !ok {
|
||||
log.Warn("reasoning_effort dropped: unrecognized value",
|
||||
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
|
||||
return ""
|
||||
}
|
||||
log.Warn("reasoning_effort downgraded: value not accepted by provider",
|
||||
"provider", string(vendor), "field", "reasoning_effort",
|
||||
"value", effort, "sent", downgraded, "model", model)
|
||||
return downgraded
|
||||
}
|
||||
|
||||
// downgradeReasoningEffort rounds an unsupported tier down to the nearest tier
|
||||
// the vendor accepts using the canonical ladder order; a tier below the vendor's
|
||||
// minimum maps to that minimum. It reports false when effort is not on the
|
||||
// ladder at all, so the caller drops the field instead of inventing a value.
|
||||
func downgradeReasoningEffort(allowed map[string]bool, effort string) (string, bool) {
|
||||
start := -1
|
||||
for i, tier := range reasoningEffortLadder {
|
||||
if tier == effort {
|
||||
start = i
|
||||
break
|
||||
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
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return "", false
|
||||
}
|
||||
for i := start; i >= 0; i-- {
|
||||
if allowed[reasoningEffortLadder[i]] {
|
||||
return reasoningEffortLadder[i], true
|
||||
}
|
||||
}
|
||||
for _, tier := range reasoningEffortLadder {
|
||||
if allowed[tier] {
|
||||
return tier, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
log.Warn("reasoning_effort dropped: unrecognized value",
|
||||
"provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model)
|
||||
return ""
|
||||
}
|
||||
|
||||
// anthropicThinkingFor resolves the extended-thinking directive together with the
|
||||
// max_tokens it requires. It returns the (possibly raised) max_tokens and the
|
||||
// wire block; a nil block means "do not send thinking" (no directive, or an
|
||||
// explicit off). Because max_tokens is shared by thinking and the visible answer,
|
||||
// the returned budget always leaves anthropicThinkingBodyReserve for the body,
|
||||
// and a request whose budget would eat the answer raises max_tokens instead — the
|
||||
// adjustment is logged so it is never a silent rewrite.
|
||||
func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *anthropicThinking) {
|
||||
if th == nil || (th.Enable != nil && !*th.Enable) {
|
||||
return maxTokens, nil
|
||||
// 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
|
||||
}
|
||||
budget := th.BudgetTokens
|
||||
if budget == 0 {
|
||||
budget = anthropicDefaultThinkingBudget
|
||||
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 budget < anthropicMinThinkingBudget {
|
||||
log.Warn("thinking budget raised to the protocol minimum",
|
||||
"provider", "anthropic", "field", "thinking.budget_tokens",
|
||||
"budget_tokens", th.BudgetTokens, "sent", anthropicMinThinkingBudget)
|
||||
budget = anthropicMinThinkingBudget
|
||||
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 need := budget + anthropicThinkingBodyReserve; maxTokens < need {
|
||||
log.Warn("max_tokens raised to leave room for thinking plus the answer",
|
||||
"provider", "anthropic", "budget_tokens", budget,
|
||||
"max_tokens_requested", maxTokens, "max_tokens_sent", need,
|
||||
"body_reserve", anthropicThinkingBodyReserve)
|
||||
maxTokens = need
|
||||
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 maxTokens, &anthropicThinking{Type: anthropicThinkingTypeEnabled, BudgetTokens: budget}
|
||||
return block, effort
|
||||
}
|
||||
|
||||
// reportUnmappedThinking logs why a Thinking directive cannot be carried by an
|
||||
// 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.
|
||||
// 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 {
|
||||
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, 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
|
||||
@@ -199,9 +277,6 @@ func reportUnmappedThinking(log *slog.Logger, vendor reasoningVendor, model stri
|
||||
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)
|
||||
case vendorDeepSeek:
|
||||
log.Warn("deepseek thinking switch not mapped in this version; only reasoning_effort is forwarded",
|
||||
"provider", "deepseek", "field", "thinking.type", "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)
|
||||
|
||||
Reference in new issue
Block a user