返回 DeepSeek-Reasonix
reasoning_replay_policy_test.go
根目录 / internal / provider / openai / reasoning_replay_policy_test.go
1 package openai
2
3 import (
4 "encoding/json"
5 "strings"
6 "testing"
7
8 "reasonix/internal/provider"
9 )
10
11 func TestDeepSeekReplaysEveryReasoningCarryingAssistantTurn(t *testing.T) {
12 p, err := New(provider.Config{
13 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKey: "k",
14 Extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled"},
15 })
16 if err != nil {
17 t.Fatalf("New: %v", err)
18 }
19 if !provider.RequiresAssistantReasoningReplay(p, provider.Message{
20 Role: provider.RoleAssistant, Content: "plain", ReasoningContent: "provider reasoning",
21 }) {
22 t.Fatal("DeepSeek plain assistant reasoning must be replay-required")
23 }
24 if provider.RequiresAssistantReasoningReplay(p, provider.Message{
25 Role: provider.RoleAssistant, Content: "plain",
26 }) {
27 t.Fatal("DeepSeek plain assistant without reasoning must not invent a replay requirement")
28 }
29 if !provider.RequiresAssistantReasoningReplay(p, provider.Message{
30 Role: provider.RoleAssistant,
31 ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "read_file"}},
32 }) {
33 t.Fatal("DeepSeek tool turn must remain replay-required when reasoning is missing")
34 }
35
36 disabled := p.(*client)
37 disabled.thinkingType = "disabled"
38 if !provider.RequiresAssistantReasoningReplay(disabled, provider.Message{
39 Role: provider.RoleAssistant, Content: "plain", ReasoningContent: "previous reasoning",
40 }) {
41 t.Fatal("thinking-disabled DeepSeek must still replay stored reasoning")
42 }
43 if provider.RequiresAssistantReasoningReplay(disabled, provider.Message{
44 Role: provider.RoleAssistant,
45 ToolCalls: []provider.ToolCall{{ID: "call_2", Name: "read_file"}},
46 }) {
47 t.Fatal("thinking-disabled DeepSeek must not require missing tool reasoning")
48 }
49 }
50
51 func TestGLMPreservesIssuedReasoningAndAllowsEmptyToolFallback(t *testing.T) {
52 p, err := New(provider.Config{
53 Name: "glm", BaseURL: "https://open.bigmodel.cn/api/coding/paas/v4", Model: "glm-5.2", APIKey: "k",
54 Extra: map[string]any{"reasoning_protocol": "glm"},
55 })
56 if err != nil {
57 t.Fatalf("New: %v", err)
58 }
59 if !provider.RequiresReasoningRoundTrip(p) {
60 t.Fatal("thinking-enabled GLM must preserve provider-issued reasoning")
61 }
62 if !provider.RequiresAssistantReasoningReplay(p, provider.Message{Role: provider.RoleAssistant, ReasoningContent: "provider reasoning"}) {
63 t.Fatal("GLM must replay reasoning the provider emitted")
64 }
65 if provider.RequiresAssistantReasoningReplay(p, provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "read_file"}}}) {
66 t.Fatal("GLM tool turns without provider reasoning must remain replayable")
67 }
68 if !provider.AllowsEmptyReasoningFallback(p) {
69 t.Fatal("GLM must accept an empty reasoning_content value when the provider emitted none")
70 }
71 if provider.RequiresAssistantReasoningReplay(p, provider.Message{Role: provider.RoleAssistant, Content: "plain answer"}) {
72 t.Fatal("GLM plain answers without reasoning must remain replayable")
73 }
74 }
75
76 func TestGLMSerializesEmptyReasoningContentForToolHistory(t *testing.T) {
77 p, err := New(provider.Config{
78 Name: "glm", BaseURL: "https://open.bigmodel.cn/api/coding/paas/v4", Model: "glm-5.2", APIKey: "k",
79 Extra: map[string]any{"reasoning_protocol": "glm"},
80 })
81 if err != nil {
82 t.Fatalf("New: %v", err)
83 }
84 req := p.(*client).buildRequest(provider.Request{Messages: []provider.Message{
85 {Role: provider.RoleUser, Content: "inspect"},
86 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "read_file", Arguments: `{}`}}},
87 {Role: provider.RoleTool, ToolCallID: "call_1", Name: "read_file", Content: "package main"},
88 }})
89 if got := req.Messages[1].ReasoningContent; got == nil || *got != "" {
90 t.Fatalf("GLM empty tool reasoning_content = %v, want explicit empty string", got)
91 }
92 }
93
94 // TestBuildRequestSendsEmptyReasoningKeyOnPlainDeepSeekTurn guards the wire
95 // contract behind the repeated 400s: under the deepseek reasoning protocol in
96 // thinking mode, the API rejects an assistant history turn whose
97 // reasoning_content KEY is missing — even a plain text turn that carried no
98 // reasoning and no tool call. The key must serialize as an empty string.
99 // Generic thinking mode and non-DeepSeek backends must keep omitting it on
100 // plain turns, so the fix hinges on the protocol, not on thinking alone.
101 func TestBuildRequestSendsEmptyReasoningKeyOnPlainDeepSeekTurn(t *testing.T) {
102 msgs := []provider.Message{
103 {Role: provider.RoleUser, Content: "explain"},
104 {Role: provider.RoleAssistant, Content: "plain answer"},
105 {Role: provider.RoleUser, Content: "thanks"},
106 }
107 deepseek, err := json.Marshal((&client{model: "deepseek-v4", deepseek: true, thinkingType: "enabled"}).buildRequest(provider.Request{Messages: msgs}).Messages)
108 if err != nil {
109 t.Fatalf("marshal deepseek: %v", err)
110 }
111 var req []map[string]json.RawMessage
112 if err := json.Unmarshal(deepseek, &req); err != nil {
113 t.Fatalf("unmarshal deepseek: %v", err)
114 }
115 rc, ok := req[1]["reasoning_content"]
116 if !ok {
117 t.Fatal("plain DeepSeek assistant turn must still serialize the reasoning_content key")
118 }
119 if string(rc) != `""` {
120 t.Fatalf("reasoning_content = %s, want empty string", rc)
121 }
122
123 // Generic thinking mode (RequiresToolCallReasoning without the deepseek
124 // protocol) must NOT emit the key on a plain turn — only the deepseek
125 // reasoning protocol demands it.
126 generic, err := json.Marshal((&client{model: "mimo-v2", thinkingType: "enabled"}).buildRequest(provider.Request{Messages: msgs}).Messages)
127 if err != nil {
128 t.Fatalf("marshal generic: %v", err)
129 }
130 if strings.Contains(string(generic), "reasoning_content") {
131 t.Fatalf("generic thinking mode must not serialize reasoning_content on a plain turn: %s", generic)
132 }
133
134 // The deepseek protocol with thinking disabled keeps the old guard: a plain
135 // turn carries no key.
136 off, err := json.Marshal((&client{model: "deepseek-v4", deepseek: true, thinkingType: "disabled"}).buildRequest(provider.Request{Messages: msgs}).Messages)
137 if err != nil {
138 t.Fatalf("marshal deepseek-off: %v", err)
139 }
140 if strings.Contains(string(off), "reasoning_content") {
141 t.Fatalf("deepseek protocol with thinking disabled must not serialize reasoning_content on a plain turn: %s", off)
142 }
143
144 other, err := json.Marshal((&client{model: "mimo-v2"}).buildRequest(provider.Request{Messages: msgs}).Messages)
145 if err != nil {
146 t.Fatalf("marshal other: %v", err)
147 }
148 if strings.Contains(string(other), "reasoning_content") {
149 t.Fatalf("non-DeepSeek backends must not serialize reasoning_content on a plain turn: %s", other)
150 }
151 }
152
152 lines GO