From 0de68bc960985560f27a7dc4df6e683a58a880ad Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 20:22:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(model):=20DeepSeek=20=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=AF=AD=E4=B9=89=E5=8F=8D=E8=BD=AC=20+=20th?= =?UTF-8?q?inking=20=E5=BC=80=E5=85=B3=E7=9C=9F=E5=8F=91=EF=BC=88=E6=8C=89?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E6=96=87=E6=A1=A3=E6=A0=B8=E5=AF=B9=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 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) --- .env.example | 3 + internal/runtime/model/anthropic.go | 82 +++-- .../anthropic_deepseek_reasoning_test.go | 342 ++++++++++++++++++ internal/runtime/model/anthropic_reasoning.go | 126 +++++++ .../runtime/model/deepseek_reasoning_test.go | 307 ++++++++++++++++ internal/runtime/model/model.go | 25 +- internal/runtime/model/openai_encode.go | 26 +- internal/runtime/model/reasoning.go | 267 +++++++++----- internal/runtime/model/reasoning_test.go | 21 +- internal/runtime/model/runtime_test.go | 6 +- internal/services/gateway/admin_write_test.go | 20 +- 11 files changed, 1076 insertions(+), 149 deletions(-) create mode 100644 internal/runtime/model/anthropic_deepseek_reasoning_test.go create mode 100644 internal/runtime/model/anthropic_reasoning.go create mode 100644 internal/runtime/model/deepseek_reasoning_test.go diff --git a/.env.example b/.env.example index 3eae5c2..2398926 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,9 @@ LLM_FALLBACK_MAX_TOKENS=8192 # ANTHROPIC_API_KEY=sk-ant-xxxxx # ANTHROPIC_MODEL=claude-sonnet-4-20250514 # ANTHROPIC_FALLBACK_MODEL=claude-haiku-3-5 +# ⚠️ 若 ANTHROPIC_BASE_URL 指向 DeepSeek(https://api.deepseek.com/anthropic),模型名须用 DeepSeek 当前名称 +# (deepseek-flash / deepseek-v4-pro):不认识的名称(如 claude-*)会被服务端【静默映射成 deepseek-flash】, +# 配置错了不会报错。旧的 deepseek-chat / deepseek-reasoner 已于 2026-07-24 停用。 # ---- 记忆嵌入(可选,真语义) ---- # 不配置时使用内置 n-gram / FTS 向量(dim=256)。配置后必须真实走语义路径。 diff --git a/internal/runtime/model/anthropic.go b/internal/runtime/model/anthropic.go index c42cdfc..9e085ee 100644 --- a/internal/runtime/model/anthropic.go +++ b/internal/runtime/model/anthropic.go @@ -32,24 +32,34 @@ const ( // anthropicThinking is the wire form of the extended-thinking directive. It is // omitted entirely when the caller did not ask for thinking (or asked for it to -// be off), because Anthropic treats a missing field as "no thinking". +// be off), because Anthropic treats a missing field as "no thinking". DeepSeek's +// /anthropic endpoint instead defaults to enabled, so an explicit off is sent as +// {"type":"disabled"} with no budget (design/04 §3.2 fact 2). type anthropicThinking struct { Type string `json:"type"` - BudgetTokens int `json:"budget_tokens"` + BudgetTokens int `json:"budget_tokens,omitempty"` +} + +// anthropicOutputConfig is the Anthropic-format reasoning strength block. Only +// DeepSeek's /anthropic endpoint documents it (and only the effort field), so it +// is never sent to the Anthropic endpoint (design/04 §3.2 fact 6). +type anthropicOutputConfig struct { + Effort string `json:"effort"` } // anthropicRequest is the body sent to /v1/messages. Messages carry Anthropic's // content-block model and the system prompt lives at the request root. type anthropicRequest struct { - Model string `json:"model"` - System string `json:"system,omitempty"` - Messages []anthropicMessage `json:"messages"` - Tools []anthropicTool `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens"` - Thinking *anthropicThinking `json:"thinking,omitempty"` - Stream bool `json:"stream,omitempty"` + Model string `json:"model"` + System string `json:"system,omitempty"` + Messages []anthropicMessage `json:"messages"` + Tools []anthropicTool `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens"` + Thinking *anthropicThinking `json:"thinking,omitempty"` + OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"` + Stream bool `json:"stream,omitempty"` } // anthropicMessage is one conversation turn. Content is an array of content @@ -293,19 +303,33 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e return nil, err } tokenLimit := effectiveMaxTokens(req.MaxTokens, anthropicDefaultMaxTokens) - // Extended thinking shares max_tokens with the visible answer, so the budget - // is resolved together with the limit it requires (design/04 §3.2 red line). - tokenLimit, thinking := anthropicThinkingFor(req.Thinking, tokenLimit, a.log) + var thinking *anthropicThinking + var output *anthropicOutputConfig + if deepSeekAnthropicEndpoint(req.Model, a.baseURL) { + // A DeepSeek /anthropic endpoint ignores budget_tokens, so max_tokens is + // deliberately not raised here: raising it would misreport the budget the + // provider actually applies. Strength goes through output_config.effort + // instead (design/04 §3.2 fact 6). + thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort) + reportForcedToolChoiceWithThinking(a.log, req.Model, req.ToolChoice, anthropicThinkingOn(thinking)) + } else { + // Extended thinking shares max_tokens with the visible answer, so on the + // Anthropic endpoint the budget is resolved together with the limit it + // requires (design/04 §3.2 red line). + tokenLimit, thinking = anthropicThinkingFor(req.Thinking, tokenLimit, a.log) + a.reportAnthropicEffortIgnored(req) + } wire := anthropicRequest{ - Model: req.Model, - System: system, - Messages: msgs, - Tools: buildAnthropicTools(req.Tools), - ToolChoice: req.ToolChoice, - Temperature: req.Temperature, - MaxTokens: tokenLimit, - Thinking: thinking, - Stream: stream, + Model: req.Model, + System: system, + Messages: msgs, + Tools: buildAnthropicTools(req.Tools), + ToolChoice: req.ToolChoice, + Temperature: req.Temperature, + MaxTokens: tokenLimit, + Thinking: thinking, + OutputConfig: output, + Stream: stream, } buf, err := json.Marshal(wire) if err != nil { @@ -314,6 +338,18 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e return buf, nil } +// reportAnthropicEffortIgnored reports a reasoning_effort on an endpoint that has +// no field for it: the Anthropic Messages protocol carries thinking strength as +// budget_tokens. It only reports — inventing a budget would change the caller's +// intent. +func (a *AnthropicChatAdapter) reportAnthropicEffortIgnored(req Request) { + if strings.TrimSpace(req.ReasoningEffort) == "" { + return + } + a.log.Warn("reasoning_effort ignored: the Anthropic Messages protocol takes thinking.budget_tokens, not an effort tier", + "provider", "anthropic", "field", "reasoning_effort", "value", req.ReasoningEffort, "model", req.Model) +} + func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) { if a.apiKey != "" { httpReq.Header.Set("x-api-key", a.apiKey) diff --git a/internal/runtime/model/anthropic_deepseek_reasoning_test.go b/internal/runtime/model/anthropic_deepseek_reasoning_test.go new file mode 100644 index 0000000..e11c992 --- /dev/null +++ b/internal/runtime/model/anthropic_deepseek_reasoning_test.go @@ -0,0 +1,342 @@ +package model + +import ( + "context" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// TestDeepSeekAnthropicEndpoint_Classification pins the endpoint detection: a +// DeepSeek /anthropic URL must be recognized even when the configured model name +// is not a DeepSeek one (the service silently maps unknown names onto +// deepseek-flash, so the name is not a reliable signal there). +func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) { + t.Parallel() + cases := []struct { + model string + baseURL string + want bool + }{ + {model: deepSeekModel, baseURL: "http://127.0.0.1:9", want: true}, + {model: "claude-sonnet-4", baseURL: "https://api.deepseek.com/anthropic", want: true}, + {model: "gpt-4o", baseURL: "https://api.deepseek.com/anthropic", want: true}, + {model: "claude-sonnet-4", baseURL: "https://api.anthropic.com", want: false}, + {model: "deepseek-v4-pro", baseURL: "https://gateway.internal/anthropic", want: true}, + } + for _, tc := range cases { + if got := deepSeekAnthropicEndpoint(tc.model, tc.baseURL); got != tc.want { + t.Errorf("deepSeekAnthropicEndpoint(%q, %q) = %v, want %v", tc.model, tc.baseURL, got, tc.want) + } + } +} + +// newDeepSeekAnthropicAdapter builds an Anthropic-protocol adapter whose model +// name identifies DeepSeek, so the DeepSeek /anthropic branch is exercised while +// the request still goes to a local stub. +func newDeepSeekAnthropicAdapter(t *testing.T, ch chan<- anthropicCaptured, opts ...AnthropicOption) *AnthropicChatAdapter { + t.Helper() + srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) + return NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: deepSeekModel}, opts...) +} + +// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens asserts the max_tokens raise +// stays on the Anthropic endpoint: DeepSeek's /anthropic ignores budget_tokens, so +// raising the limit there would misreport the budget the provider applies. +func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + log, buf := captureLog() + ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) + + const budget = 8192 + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + MaxTokens: 1024, + Thinking: &Thinking{BudgetTokens: budget}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if got := body["max_tokens"]; got != float64(1024) { + t.Fatalf("body.max_tokens = %v, want the requested 1024 (DeepSeek ignores budget_tokens)", got) + } + think, ok := body["thinking"].(map[string]any) + if !ok || think["type"] != thinkingTypeEnabled { + t.Fatalf("body.thinking = %v, want an enabled directive", body["thinking"]) + } + rec := buf.String() + for _, want := range []string{"level=WARN", "budget_tokens", "ignored", "deepseek"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } + if strings.Contains(rec, "max_tokens raised") { + t.Errorf("log %q reports a max_tokens raise that must not happen on the DeepSeek endpoint", rec) + } +} + +// TestAnthropicOfficial_BudgetStillRaisesMaxTokens is the counterpart guard: on +// the Anthropic endpoint the design red line ("budget must not eat the answer") +// still holds. +func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) + log, buf := captureLog() + ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}, + WithAnthropicLogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "claude-test", + Messages: []Message{TextMessage(RoleUser, "hi")}, + MaxTokens: 1024, + Thinking: &Thinking{BudgetTokens: 4096}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + maxTokens, _ := body["max_tokens"].(float64) + think, _ := body["thinking"].(map[string]any) + budget, _ := think["budget_tokens"].(float64) + if maxTokens <= budget { + t.Fatalf("body.max_tokens = %v, want > budget_tokens %v on the Anthropic endpoint", maxTokens, budget) + } + if !strings.Contains(buf.String(), "max_tokens raised") { + t.Errorf("log %q, want the raise WARN", buf.String()) + } +} + +// TestAnthropicDeepSeek_OutputConfigEffort asserts the DeepSeek /anthropic +// strength path exists: the effort becomes output_config.effort, and the aliases +// are normalized to the tiers the service applies. +func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) { + t.Parallel() + cases := []struct{ sent, want string }{ + {"low", "low"}, + {"high", "high"}, + {"max", "max"}, + {"minimal", "low"}, + {"medium", "high"}, + {"xhigh", "high"}, + {"ultra", "max"}, + } + for _, tc := range cases { + t.Run(tc.sent, func(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + ad := newDeepSeekAnthropicAdapter(t, ch) + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: tc.sent, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + output, ok := body["output_config"].(map[string]any) + if !ok { + t.Fatalf("body.output_config = %v, want an effort block", body["output_config"]) + } + if got := output["effort"]; got != tc.want { + t.Fatalf("body.output_config.effort = %v, want %q (sent %q)", got, tc.want, tc.sent) + } + }) + } +} + +// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped asserts an unrecognized +// effort is dropped and reported rather than approximated. +func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + log, buf := captureLog() + ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: "foo", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if _, present := body["output_config"]; present { + t.Fatalf("body.output_config = %v, want the block dropped", body["output_config"]) + } + if _, present := body["reasoning_effort"]; present { + t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) + } + rec := buf.String() + for _, want := range []string{"level=WARN", "reasoning_effort", "foo", "deepseek"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// TestAnthropicDeepSeek_EffortNoneDisablesThinking pins the translation of the +// one effort value output_config cannot carry: DeepSeek's Anthropic format has no +// effort "none", so the off intent goes through the thinking switch — mapping it +// onto a tier would turn thinking off into thinking on. +func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + log, buf := captureLog() + ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: effortNone, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + think, ok := body["thinking"].(map[string]any) + if !ok || think["type"] != thinkingTypeDisabled { + t.Fatalf("body.thinking = %v, want %q", body["thinking"], thinkingTypeDisabled) + } + if _, present := body["output_config"]; present { + t.Fatalf("body.output_config = %v, want no effort block", body["output_config"]) + } + if rec := buf.String(); !strings.Contains(rec, "none") || !strings.Contains(rec, "level=WARN") { + t.Errorf("log %q, want a WARN explaining the translation", rec) + } +} + +// TestAnthropicDeepSeek_ThinkingSwitch asserts an explicit switch is carried as +// the Anthropic-format thinking block, and that a disabled one omits the budget +// (DeepSeek ignores it anyway). +func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) { + t.Parallel() + cases := []struct { + name string + enable *bool + wantType string + absent bool + }{ + {name: "explicit_off", enable: boolPtr(false), wantType: thinkingTypeDisabled}, + {name: "explicit_on", enable: boolPtr(true), wantType: thinkingTypeEnabled}, + {name: "nil_leaves_the_default", enable: nil, absent: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + ad := newDeepSeekAnthropicAdapter(t, ch) + req := Request{Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}} + if tc.enable != nil { + req.Thinking = &Thinking{Enable: tc.enable} + } + if _, err := ad.Chat(context.Background(), req, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + think, present := body["thinking"].(map[string]any) + if tc.absent { + if present { + t.Fatalf("body.thinking = %v, want the field omitted", body["thinking"]) + } + return + } + if !present || think["type"] != tc.wantType { + t.Fatalf("body.thinking = %v, want type %q", body["thinking"], tc.wantType) + } + if _, hasBudget := think["budget_tokens"]; tc.wantType == thinkingTypeDisabled && hasBudget { + t.Errorf("body.thinking = %v, want no budget_tokens for a disabled switch", think) + } + }) + } +} + +// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled keeps the "off wins" +// rule on the Anthropic path too: output_config.effort must not re-open thinking. +func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + log, buf := captureLog() + ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(false)}, + ReasoningEffort: "high", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if _, present := body["output_config"]; present { + t.Fatalf("body.output_config = %v, want it dropped while thinking is off", body["output_config"]) + } + if think, _ := body["thinking"].(map[string]any); think["type"] != thinkingTypeDisabled { + t.Fatalf("body.thinking = %v, want %q", body["thinking"], thinkingTypeDisabled) + } + if rec := buf.String(); !strings.Contains(rec, "explicitly disabled") { + t.Errorf("log %q, want a WARN explaining the dropped effort", rec) + } +} + +// TestAnthropicOfficial_ReasoningEffortReportedIgnored asserts a reasoning_effort +// aimed at the Anthropic endpoint is reported instead of silently disappearing — +// the Messages protocol has no such field. +func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) + log, buf := captureLog() + ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}, + WithAnthropicLogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: "claude-test", + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: "high", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + for _, key := range []string{"reasoning_effort", "output_config"} { + if _, present := body[key]; present { + t.Errorf("body.%s = %v, want the Anthropic endpoint untouched", key, body[key]) + } + } + rec := buf.String() + for _, want := range []string{"level=WARN", "reasoning_effort", "high", "anthropic"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns covers the DeepSeek 400 +// trap on the Anthropic path: the adapter warns and still sends the directive — +// it never closes thinking on its own. +func TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns(t *testing.T) { + t.Parallel() + ch := make(chan anthropicCaptured, 1) + log, buf := captureLog() + ad := newDeepSeekAnthropicAdapter(t, ch, WithAnthropicLogger(log)) + + choice := map[string]any{"type": "tool", "name": "get_time"} + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ToolChoice: choice, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + if got := (<-ch).body["tool_choice"]; got == nil { + t.Fatal("body.tool_choice = nil, want the directive passed through verbatim") + } + rec := buf.String() + for _, want := range []string{"level=WARN", "tool_choice", "deepseek", deepSeekModel} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} diff --git a/internal/runtime/model/anthropic_reasoning.go b/internal/runtime/model/anthropic_reasoning.go new file mode 100644 index 0000000..0c63f85 --- /dev/null +++ b/internal/runtime/model/anthropic_reasoning.go @@ -0,0 +1,126 @@ +package model + +import ( + "log/slog" + "strings" +) + +// Anthropic extended-thinking protocol bounds (design/04 §3.2). +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 +) + +// deepSeekAnthropicEndpoint reports whether an Anthropic-protocol request is +// really served by DeepSeek's /anthropic endpoint. The base URL is checked first +// because the endpoint — not the model name — decides whether budget_tokens is +// honoured, and a DeepSeek endpoint is commonly configured with a non-DeepSeek +// model name (the service silently maps unknown names onto deepseek-flash). +func deepSeekAnthropicEndpoint(model, baseURL string) bool { + if strings.Contains(strings.ToLower(baseURL), "deepseek") { + return true + } + return reasoningVendorFor(model, baseURL) == vendorDeepSeek +} + +// 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. +// +// This is the Anthropic endpoint's rule only. A DeepSeek /anthropic endpoint +// ignores budget_tokens, so it must not go through here (design/04 §3.2 fact 6). +func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *anthropicThinking) { + if th == nil || (th.Enable != nil && !*th.Enable) { + return maxTokens, nil + } + budget := th.BudgetTokens + if budget == 0 { + budget = anthropicDefaultThinkingBudget + } + 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 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 + } + return maxTokens, &anthropicThinking{Type: anthropicThinkingTypeEnabled, BudgetTokens: budget} +} + +// anthropicDeepSeekReasoning resolves the reasoning knobs for a DeepSeek +// /anthropic request. Two differences from the Anthropic endpoint drive it: that +// endpoint ignores budget_tokens, so max_tokens is left untouched (raising it +// would misreport the budget the provider actually applies), and strength is +// controlled by output_config.effort, which has no "none" value — there the "off" +// intent is carried by the thinking switch itself (design/04 §3.2 facts 2 and 6). +func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) (*anthropicThinking, *anthropicOutputConfig) { + var block *anthropicThinking + if th != nil { + if th.BudgetTokens > 0 { + log.Warn("thinking.budget_tokens ignored by the DeepSeek /anthropic endpoint: strength is output_config.effort, so max_tokens was not raised for the budget", + "provider", "deepseek", "field", "thinking.budget_tokens", + "budget_tokens", th.BudgetTokens, "model", model) + } + switch { + case th.Enable != nil: + block = &anthropicThinking{Type: thinkingSwitchValue(*th.Enable), BudgetTokens: th.BudgetTokens} + case th.BudgetTokens > 0: + block = &anthropicThinking{Type: thinkingTypeEnabled, BudgetTokens: th.BudgetTokens} + } + } + effort = strings.TrimSpace(effort) + if effort == "" { + return block, nil + } + if effort == effortNone { + if block == nil || block.Type == thinkingTypeEnabled { + log.Warn(`reasoning_effort none mapped to thinking.type=disabled: the DeepSeek /anthropic endpoint has no output_config.effort "none"`, + "provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model) + } + return &anthropicThinking{Type: thinkingTypeDisabled}, nil + } + if block != nil && block.Type == thinkingTypeDisabled { + log.Warn("output_config.effort dropped: thinking is explicitly disabled", + "provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model) + return block, nil + } + tier, ok := normalizeDeepSeekEffort(effort) + if !ok { + log.Warn("reasoning_effort dropped: unrecognized value", + "provider", "deepseek", "field", "reasoning_effort", "value", effort, "model", model) + return block, nil + } + if tier != effort { + log.Warn("reasoning_effort alias normalized to the tier DeepSeek applies", + "provider", "deepseek", "field", "reasoning_effort", + "value", effort, "sent", tier, "model", model) + } + return block, &anthropicOutputConfig{Effort: tier} +} + +// anthropicThinkingOn reports the effective thinking state of a resolved DeepSeek +// /anthropic request: an omitted switch leaves the provider default in place, and +// DeepSeek's default is enabled. +func anthropicThinkingOn(th *anthropicThinking) bool { + return th == nil || th.Type != thinkingTypeDisabled +} diff --git a/internal/runtime/model/deepseek_reasoning_test.go b/internal/runtime/model/deepseek_reasoning_test.go new file mode 100644 index 0000000..6fc6d46 --- /dev/null +++ b/internal/runtime/model/deepseek_reasoning_test.go @@ -0,0 +1,307 @@ +package model + +import ( + "context" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// newDeepSeekAdapter builds an OpenAI-compatible adapter pointed at a stub server +// but classified as DeepSeek by its model name, so the DeepSeek mapping is +// exercised against the real request body. +func newDeepSeekAdapter(t *testing.T, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter { + t.Helper() + srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) + return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: deepSeekModel}, opts...) +} + +// deepSeekModel is the current DeepSeek model name (the old deepseek-chat / +// deepseek-reasoner names were discontinued on 2026-07-24). +const deepSeekModel = "deepseek-flash" + +// deepSeekThinkingType reads body.thinking.type, reporting whether the switch was +// sent at all. +func deepSeekThinkingType(body map[string]any) (string, bool) { + block, ok := body["thinking"].(map[string]any) + if !ok { + return "", false + } + value, _ := block["type"].(string) + return value, true +} + +// TestOpenAIChat_DeepSeekReasoningEffortMatrix pins the official value set end to +// end at the request body: none/low/high/max pass through untouched and the four +// compatibility aliases are normalized to the tier DeepSeek applies +// (minimal→low, medium/xhigh→high, ultra→max). The old behavior — grading "none" +// down to "low" — turned thinking off into thinking on, so "none" must arrive as +// "none". +func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) { + t.Parallel() + cases := []struct{ sent, want string }{ + {"none", "none"}, + {"low", "low"}, + {"high", "high"}, + {"max", "max"}, + {"minimal", "low"}, + {"medium", "high"}, + {"xhigh", "high"}, + {"ultra", "max"}, + } + for _, tc := range cases { + t.Run(tc.sent, func(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + ad := newDeepSeekAdapter(t, ch) + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: tc.sent, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if got := body["reasoning_effort"]; got != tc.want { + t.Fatalf("body.reasoning_effort = %v, want %q (sent %q)", got, tc.want, tc.sent) + } + if _, present := body["thinking"]; present { + t.Errorf("body.thinking = %v, want no switch invented from an effort", body["thinking"]) + } + }) + } +} + +// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped asserts an unrecognized +// value is dropped and reported — never replaced by a nearby tier, which is how +// the original "round down" logic inverted the caller's intent. +func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: "foo", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if _, present := body["reasoning_effort"]; present { + t.Fatalf("body.reasoning_effort = %v, want the field dropped", body["reasoning_effort"]) + } + if _, present := body["thinking"]; present { + t.Fatalf("body.thinking = %v, want no switch invented", body["thinking"]) + } + rec := buf.String() + for _, want := range []string{"level=WARN", "reasoning_effort", "foo", deepSeekModel, "deepseek"} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// TestOpenAIChat_DeepSeekThinkingSwitch asserts the DeepSeek thinking switch is +// really sent: an explicit off must reach the wire (DeepSeek defaults to enabled, +// so staying silent would leave thinking ON), and nil must not be turned into +// either value. +func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) { + t.Parallel() + cases := []struct { + name string + enable *bool + wantType string + wantAbsent bool + }{ + {name: "explicit_off", enable: boolPtr(false), wantType: thinkingTypeDisabled}, + {name: "explicit_on", enable: boolPtr(true), wantType: thinkingTypeEnabled}, + {name: "nil_leaves_the_default", enable: nil, wantAbsent: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + ad := newDeepSeekAdapter(t, ch) + req := Request{Model: deepSeekModel, Messages: []Message{TextMessage(RoleUser, "hi")}} + if tc.enable != nil { + req.Thinking = &Thinking{Enable: tc.enable} + } + if _, err := ad.Chat(context.Background(), req, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + got, present := deepSeekThinkingType((<-ch).body) + if tc.wantAbsent { + if present { + t.Fatalf("body.thinking.type = %q, want the field omitted (not equal to off)", got) + } + return + } + if !present || got != tc.wantType { + t.Fatalf("body.thinking.type = %q (present=%v), want %q", got, present, tc.wantType) + } + }) + } +} + +// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch asserts the +// official reasoning_effort "none" path is used verbatim when no thinking switch +// was requested — that is the documented way to close thinking. +func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + ad := newDeepSeekAdapter(t, ch) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + ReasoningEffort: effortNone, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if got := body["reasoning_effort"]; got != effortNone { + t.Fatalf("body.reasoning_effort = %v, want %q", got, effortNone) + } + if _, present := body["thinking"]; present { + t.Fatalf("body.thinking = %v, want no switch block", body["thinking"]) + } +} + +// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort is the conflict rule documented in +// resolveOpenAIReasoning: the provider states no precedence, so the off side wins +// and the dropped tier is reported — never silently applied. +func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(false)}, + ReasoningEffort: "high", + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if got, _ := deepSeekThinkingType(body); got != thinkingTypeDisabled { + t.Fatalf("body.thinking.type = %q, want %q", got, thinkingTypeDisabled) + } + if _, present := body["reasoning_effort"]; present { + t.Fatalf("body.reasoning_effort = %v, want it dropped while thinking is explicitly off", body["reasoning_effort"]) + } + if rec := buf.String(); !strings.Contains(rec, "explicitly disabled") { + t.Errorf("log %q, want a WARN explaining the dropped reasoning_effort", rec) + } +} + +// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled covers the opposite +// conflict: reasoning_effort "none" and thinking enabled cannot both be honoured, +// so the off side wins and the request carries exactly one switch. +func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{Enable: boolPtr(true)}, + ReasoningEffort: effortNone, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if got, _ := deepSeekThinkingType(body); got != thinkingTypeDisabled { + t.Fatalf("body.thinking.type = %q, want %q", got, thinkingTypeDisabled) + } + if _, present := body["reasoning_effort"]; present { + t.Fatalf("body.reasoning_effort = %v, want it dropped in favour of the switch", body["reasoning_effort"]) + } + if rec := buf.String(); !strings.Contains(rec, "contradicts") { + t.Errorf("log %q, want a WARN about the contradiction", rec) + } +} + +// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored asserts a token budget +// (an Anthropic-style directive) is reported as unmappable on the DeepSeek +// chat-completions endpoint instead of opening or closing the switch by guess. +func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log)) + + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: &Thinking{BudgetTokens: 8192}, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + body := (<-ch).body + if _, present := body["thinking"]; present { + t.Fatalf("body.thinking = %v, want no switch block", body["thinking"]) + } + rec := buf.String() + for _, want := range []string{"level=WARN", "thinking.budget_tokens", "deepseek", deepSeekModel} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } +} + +// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns covers the DeepSeek +// 400 trap: a forcing tool_choice while thinking is on is rejected, and thinking +// is on by default. The adapter only warns — it must never close thinking by +// itself. +func TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns(t *testing.T) { + t.Parallel() + cases := []struct { + name string + thinking *Thinking + toolChoice any + wantWarn bool + }{ + {name: "required_with_default_thinking", toolChoice: map[string]any{"type": "required"}, wantWarn: true}, + {name: "named_tool_with_thinking_on", thinking: &Thinking{Enable: boolPtr(true)}, + toolChoice: map[string]any{"type": "function", "function": map[string]any{"name": "x"}}, wantWarn: true}, + {name: "auto_is_not_forcing", toolChoice: map[string]any{"type": "auto"}}, + {name: "forced_with_thinking_off", thinking: &Thinking{Enable: boolPtr(false)}, + toolChoice: map[string]any{"type": "required"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ch := make(chan capturedRequest, 1) + log, buf := captureLog() + ad := newDeepSeekAdapter(t, ch, WithOpenAILogger(log)) + if _, err := ad.Chat(context.Background(), Request{ + Model: deepSeekModel, + Messages: []Message{TextMessage(RoleUser, "hi")}, + Thinking: tc.thinking, + ToolChoice: tc.toolChoice, + }, Options{}); err != nil { + t.Fatalf("Chat error: %v", err) + } + rec := buf.String() + if tc.wantWarn { + for _, want := range []string{"level=WARN", "tool_choice", "deepseek", deepSeekModel} { + if !strings.Contains(rec, want) { + t.Errorf("log %q missing %q", rec, want) + } + } + return + } + if strings.Contains(rec, "tool_choice") { + t.Errorf("unexpected tool_choice WARN: %q", rec) + } + }) + } +} diff --git a/internal/runtime/model/model.go b/internal/runtime/model/model.go index 169fbae..7725ef4 100644 --- a/internal/runtime/model/model.go +++ b/internal/runtime/model/model.go @@ -158,6 +158,15 @@ type Request struct { Model string Messages []Message // Temperature is the sampling temperature. nil means the provider default. + // + // DeepSeek notes (design/04 §3.2 facts 3 and 4): temperature takes no effect + // while thinking is on. frequency_penalty and presence_penalty are deprecated + // at the API level — "传入该参数将不会产生任何效果" — in every mode, which is + // why Request has no field for them. top_p is deliberately not exposed + // either: it only applies while thinking is on and is clamped to >= 0.95 + // there, is always 1.0 otherwise, so sending a configured value would be + // silently ineffective on one side of the switch and a hard floor on the + // other. Temperature *float64 // MaxTokens caps the generated tokens. Zero means the provider default. MaxTokens int @@ -169,16 +178,28 @@ type Request struct { // opaque provider-agnostic directive (e.g. {"type":"auto"} or {"type":"tool", // "name":"x"}); empty means the provider default. Passed through verbatim to // protocols that support it (Anthropic's tool_choice). + // + // DeepSeek red line (design/04 §3.2 fact 5): with thinking on — its default — + // a forcing directive ("required" / "any" / a named tool) is rejected with a + // 400, so thinking must be switched off explicitly first. The adapters only + // WARN about that combination; they never switch thinking off on their own. ToolChoice any // Thinking controls the provider's reasoning budget/switch (design/04 §3.2). // nil means "do not intervene" and uses the provider default. A provider // whose protocol has no mapping for it logs a WARN rather than silently // ignoring it. + // + // "Do not intervene" is not the same as "off": DeepSeek's thinking defaults to + // enabled, so Enable=false is what closes it there (sent as + // {"thinking":{"type":"disabled"}}). Thinking *Thinking // ReasoningEffort is the OpenAI-compatible reasoning strength knob // (reasoning_effort). Empty means "do not intervene". The value is validated - // against the detected provider's whitelist; an unsupported value is - // downgraded to the nearest allowed tier (or dropped) with a WARN. + // against the detected provider's whitelist; an unrecognized value is dropped + // with a WARN and never replaced by a different tier (design/04 §3.2 fact 1). + // DeepSeek additionally documents compatibility aliases (minimal→low, + // medium/xhigh→high, ultra→max), which are normalized to the tier the service + // applies. ReasoningEffort string } diff --git a/internal/runtime/model/openai_encode.go b/internal/runtime/model/openai_encode.go index ab2a834..7e85acf 100644 --- a/internal/runtime/model/openai_encode.go +++ b/internal/runtime/model/openai_encode.go @@ -15,12 +15,24 @@ type openAIChatRequest struct { Stream bool `json:"stream,omitempty"` StreamOptions *openAIStreamOpts `json:"stream_options,omitempty"` Tools []openAITool `json:"tools,omitempty"` + // Thinking is DeepSeek's top-level thinking switch. It is sent only for + // DeepSeek and only from an explicit switch (or an explicit off that must be + // honoured): leaving it out keeps the provider default, which is ENABLED, so + // an omitted field is not a way to switch thinking off (design/04 §3.2 + // fact 2). + Thinking *openAIThinking `json:"thinking,omitempty"` // ReasoningEffort is the OpenAI-compatible reasoning strength knob. It is set // only after validation against the detected vendor's whitelist (design/04 - // §3.2); an unsupported value is downgraded or dropped, never forwarded as-is. + // §3.2); an unrecognized value is dropped, never turned into a different tier. ReasoningEffort string `json:"reasoning_effort,omitempty"` } +// openAIThinking is the wire form of DeepSeek's thinking switch: enabled or +// disabled. It is only ever sent to a DeepSeek endpoint. +type openAIThinking struct { + Type string `json:"type"` +} + // openAIStreamOpts asks the provider to include usage in the final stream chunk. type openAIStreamOpts struct { IncludeUsage bool `json:"include_usage"` @@ -71,10 +83,13 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro return nil, err } // The vendor behind an OpenAI-compatible endpoint decides which - // reasoning_effort tiers are legal; classify it from the resolved model (and - // the base URL as a fallback) before touching the request (design/04 §3.2). + // reasoning_effort tiers are legal and whether it has a thinking switch; + // classify it from the resolved model (and the base URL as a fallback) before + // touching the request (design/04 §3.2). vendor := reasoningVendorFor(req.Model, a.baseURL) - reportUnmappedThinking(a.log, vendor, req.Model, req.Thinking) + effort := resolveReasoningEffort(a.log, vendor, req.ReasoningEffort, req.Model) + thinking, effort := resolveOpenAIReasoning(a.log, vendor, req.Model, req.Thinking, effort) + reportForcedToolChoiceWithThinking(a.log, req.Model, req.ToolChoice, openAIThinkingOn(thinking, effort)) wire := openAIChatRequest{ Model: req.Model, Messages: msgs, @@ -82,7 +97,8 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro MaxTokens: req.MaxTokens, Stream: stream, Tools: buildTools(req.Tools), - ReasoningEffort: resolveReasoningEffort(a.log, vendor, req.ReasoningEffort, req.Model), + Thinking: thinking, + ReasoningEffort: effort, } if stream { wire.StreamOptions = &openAIStreamOpts{IncludeUsage: true} diff --git a/internal/runtime/model/reasoning.go b/internal/runtime/model/reasoning.go index 4b095ca..82a0a05 100644 --- a/internal/runtime/model/reasoning.go +++ b/internal/runtime/model/reasoning.go @@ -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) diff --git a/internal/runtime/model/reasoning_test.go b/internal/runtime/model/reasoning_test.go index 4493a0b..a5f387a 100644 --- a/internal/runtime/model/reasoning_test.go +++ b/internal/runtime/model/reasoning_test.go @@ -175,28 +175,29 @@ func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) { } } -// TestOpenAIChat_ReasoningEffortDowngradedForDeepSeek asserts a tier the vendor -// does not accept (DeepSeek allows only low/high/max) is rounded down to the -// nearest allowed tier and reported, never forwarded as-is. -func TestOpenAIChat_ReasoningEffortDowngradedForDeepSeek(t *testing.T) { +// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek asserts a DeepSeek +// compatibility alias is normalized to the tier the service applies (medium → +// high per the official mapping table) and reported, never graded down to a +// weaker tier of our own choosing. +func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) { t.Parallel() ch := make(chan capturedRequest, 1) srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch) log, buf := captureLog() - ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-chat"}, WithOpenAILogger(log)) + ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "deepseek-flash"}, WithOpenAILogger(log)) if _, err := ad.Chat(context.Background(), Request{ - Model: "deepseek-chat", + Model: "deepseek-flash", Messages: []Message{TextMessage(RoleUser, "hi")}, ReasoningEffort: "medium", }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } - if got := (<-ch).body["reasoning_effort"]; got != "low" { - t.Fatalf("body.reasoning_effort = %v, want the downgraded low", got) + if got := (<-ch).body["reasoning_effort"]; got != "high" { + t.Fatalf("body.reasoning_effort = %v, want the officially mapped high", got) } rec := buf.String() - for _, want := range []string{"level=WARN", "reasoning_effort", "deepseek", "medium", "low"} { + for _, want := range []string{"level=WARN", "reasoning_effort", "deepseek", "medium", "high"} { if !strings.Contains(rec, want) { t.Errorf("log %q missing %q", rec, want) } @@ -346,7 +347,7 @@ func TestReasoningVendorFor_Classification(t *testing.T) { baseURL string want reasoningVendor }{ - {model: "deepseek-reasoner", want: vendorDeepSeek}, + {model: "deepseek-v4-pro", want: vendorDeepSeek}, {model: "Qwen3-Max", want: vendorQwen}, {model: "gpt-4o", want: vendorOpenAI}, {model: "o3-mini", want: vendorOpenAI}, diff --git a/internal/runtime/model/runtime_test.go b/internal/runtime/model/runtime_test.go index da04df0..653fc88 100644 --- a/internal/runtime/model/runtime_test.go +++ b/internal/runtime/model/runtime_test.go @@ -49,11 +49,11 @@ func TestRuntimeLLM_EnvFallback(t *testing.T) { func TestRuntimeLLM_FileLoad(t *testing.T) { path := writeRTFile(t, []runtimeconfig.Group{ - {ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://api.deepseek.com/v1", APIKey: "sk-ds", Model: "deepseek-chat"}, + {ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://api.deepseek.com/v1", APIKey: "sk-ds", Model: "deepseek-flash"}, }) m := NewRuntimeLLM(path, rtEnv(), nil) - if m.Provider() != ProviderOpenAI || m.Model() != "deepseek-chat" { - t.Fatalf("Provider=%q Model=%q, want openai/deepseek-chat", m.Provider(), m.Model()) + if m.Provider() != ProviderOpenAI || m.Model() != "deepseek-flash" { + t.Fatalf("Provider=%q Model=%q, want openai/deepseek-flash", m.Provider(), m.Model()) } if m.Group().ID != "deepseek" { t.Fatalf("Group().ID = %q, want deepseek", m.Group().ID) diff --git a/internal/services/gateway/admin_write_test.go b/internal/services/gateway/admin_write_test.go index 54c011f..e6af6b1 100644 --- a/internal/services/gateway/admin_write_test.go +++ b/internal/services/gateway/admin_write_test.go @@ -231,7 +231,7 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", - map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true, "maxTokens": 1234}, tok) + map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-flash", "multimodal": true, "maxTokens": 1234}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } @@ -243,7 +243,7 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode add: %v", err) } - if !res.Persisted || res.Path != modelsPath || res.Provider.Model != "deepseek-chat" || res.Provider.MaxTokens != 1234 { + if !res.Persisted || res.Path != modelsPath || res.Provider.Model != "deepseek-flash" || res.Provider.MaxTokens != 1234 { t.Fatalf("add res = %+v", res) } @@ -257,8 +257,8 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { } // The running stack reflects the new active provider/model (hot reload). - if runtime.Provider() != "openai" || runtime.Model() != "deepseek-chat" { - t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-chat", runtime.Provider(), runtime.Model()) + if runtime.Provider() != "openai" || runtime.Model() != "deepseek-flash" { + t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-flash", runtime.Provider(), runtime.Model()) } // GET /admin/llm reflects the saved group and never leaks any API key. @@ -270,8 +270,8 @@ func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) { if err := json.Unmarshal(decodeBody(t, gBody)["data"], &list); err != nil { t.Fatalf("decode list: %v", err) } - if len(list.Providers) == 0 || list.Providers[0].Model != "deepseek-chat" || list.Providers[0].MaxTokens != 1234 { - t.Fatalf("list = %+v, want deepseek-chat first with maxTokens 1234", list.Providers) + if len(list.Providers) == 0 || list.Providers[0].Model != "deepseek-flash" || list.Providers[0].MaxTokens != 1234 { + t.Fatalf("list = %+v, want deepseek-flash first with maxTokens 1234", list.Providers) } if strings.Contains(string(gBody), "sk-ds") || strings.Contains(string(gBody), "sk-env") { t.Fatalf("API key leaked in GET /admin/llm: %s", gBody) @@ -311,7 +311,7 @@ func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) { // Seed a fully-specified group. resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", - map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true, "fallback": "deepseek-lite", "maxTokens": 1234}, tok) + map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-flash", "multimodal": true, "fallback": "deepseek-v4-flash", "maxTokens": 1234}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", resp.StatusCode, body) } @@ -320,7 +320,7 @@ func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) { // stored value — in particular maxTokens (a full replace reset it to 0) and // apiKey (a full replace wiped it). resp2, body2 := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm", - map[string]any{"id": "deepseek", "model": "deepseek-chat-v2"}, tok) + map[string]any{"id": "deepseek", "model": "deepseek-v4-pro"}, tok) if resp2.StatusCode != http.StatusCreated { t.Fatalf("edit status = %d, want 201 (body=%s)", resp2.StatusCode, body2) } @@ -331,7 +331,7 @@ func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) { t.Fatalf("decode edit: %v", err) } p := res.Provider - if p.Model != "deepseek-chat-v2" || p.MaxTokens != 1234 || p.Fallback != "deepseek-lite" || + if p.Model != "deepseek-v4-pro" || p.MaxTokens != 1234 || p.Fallback != "deepseek-v4-flash" || !p.Multimodal || p.Provider != "openai" || p.BaseURL != "https://api.deepseek.com/v1" { t.Fatalf("edit provider = %+v, want model updated and every omitted field preserved", p) } @@ -340,7 +340,7 @@ func TestAdminAddLlmProviderPartialEditPreservesFields(t *testing.T) { if warn != nil { t.Fatalf("load models warn = %v", warn) } - if len(groups) == 0 || groups[0].APIKey != "sk-ds" || groups[0].MaxTokens != 1234 || groups[0].Model != "deepseek-chat-v2" { + if len(groups) == 0 || groups[0].APIKey != "sk-ds" || groups[0].MaxTokens != 1234 || groups[0].Model != "deepseek-v4-pro" { t.Fatalf("file groups = %+v, want apiKey/maxTokens preserved and model updated", groups) } }