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
@@ -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)。配置后必须真实走语义路径。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user