File size: 11,127 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
package upstream

import (
	"encoding/json"
	"testing"
)

// assistantRC 提取输出 messages 里各 assistant 消息的 reasoning_content(缺字段返回 ""+false)。
// 返回的切片与 messages 中 assistant 消息一一对应(非 assistant 消息跳过)。
func assistantRC(t *testing.T, out []byte) []string {
	t.Helper()
	var m map[string]any
	if err := json.Unmarshal(out, &m); err != nil {
		t.Fatalf("unmarshal: %v (out=%s)", err, out)
	}
	var got []string
	msgs, _ := m["messages"].([]any)
	for _, mm := range msgs {
		msg, ok := mm.(map[string]any)
		if !ok {
			continue
		}
		role, _ := msg["role"].(string)
		if role != "assistant" {
			continue
		}
		if v, ok := msg["reasoning_content"].(string); ok {
			got = append(got, v)
		} else {
			got = append(got, "<absent>")
		}
	}
	return got
}

// TestBackfillReasoningContentDeepSeek DeepSeek 多轮一致性:历史 assistant 消息有
// reasoning 痕迹时,所有 assistant 消息必须带 reasoning_content(string)。
// 对齐官方 requiresReasoningContentOnAssistantMessages 行为。
func TestBackfillReasoningContentDeepSeek(t *testing.T) {
	cases := []struct {
		name string
		body string
		// 只断言「assistant 消息数」与「每条是否有 reasoning_content 字段」,
		// 值是 string 即可(复制或空串,由具体用例断言)。
		wantCount int
		wantVals  []string // 与 assistant 消息一一对应;空串表示任意 string
	}{
		{"assistant 带 reasoning 无 reasoning_content → 复制",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"user","content":"u"},

				{"role":"assistant","content":"a","reasoning":"thought text"}]}`,
			1, []string{"thought text"}},
		{"assistant 带 reasoning_content 原样保留",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"assistant","content":"a","reasoning_content":"already there"}]}`,
			1, []string{"already there"}},
		{"混合会话全量补上:无 reasoning 的 assistant 补空串",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"user","content":"u"},

				{"role":"assistant","content":"a1","reasoning":"t1"},

				{"role":"user","content":"u2"},

				{"role":"assistant","content":"a2"}]}`,
			2, []string{"t1", ""}},
		// 新契约下(门控 thinkingEnabled||hasTrace,issue #165):L1 注入 enabled
		// 后零痕迹 assistant 也补空串——期望从 <absent> 改为 ""(存在 string)。
		{"assistant reasoning 为空串 → 视为零痕迹但 enabled 下补空串",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"assistant","content":"a","reasoning":""}]}`,
			1, []string{""}},
		{"多 assistant 都带 reasoning 全部复制",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"assistant","content":"a1","reasoning":"r1"},

				{"role":"assistant","content":"a2","reasoning":"r2"}]}`,
			2, []string{"r1", "r2"}},
		// 官方 "string"!=typeof 语义:非 string reasoning 无从复制,落补 "" 分支。
		{"assistant reasoning 非 string 值(数字)→ 补空串",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"assistant","content":"a","reasoning":123}]}`,
			1, []string{""}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil)
			got := assistantRC(t, out)
			if len(got) != c.wantCount {
				t.Fatalf("assistant 消息数 = %d want %d (out=%s)", len(got), c.wantCount, out)
			}
			for i, want := range c.wantVals {
				if want == "" {
					continue // 任意 string(补空串/复制都接受,但必须存在)
				}
				if got[i] != want {
					t.Errorf("assistant[%d].reasoning_content = %q want %q (out=%s)", i, got[i], want, out)
				}
			}
		})
	}
}

// TestBackfillReasoningContentNoTrace 会话无 reasoning 痕迹 + thinking disabled
// → 零改动:不白白给 assistant 消息加 reasoning_content 字段。
// 官方 ReasoningContentBackfillRule 门控 = thinkingEnabled || hasTrace;disabled
// 且无痕迹时两个半边都不亮 → 不补(issue #165 前该用例不分 disabled 与否一律不补,
// 现按新契约收紧为 disabled 形态——纯 text + enabled 补空串由
// TestBackfillZeroTraceThinkingEnabled 覆盖)。
func TestBackfillReasoningContentNoTrace(t *testing.T) {
	cases := []struct {
		name string
		body string
	}{
		{"disabled + 纯 text assistant 不动",
			`{"model":"deepseek-v4-flash","thinking":{"type":"disabled"},"messages":[

				{"role":"user","content":"u"},

				{"role":"assistant","content":"plain answer"}]}`},
		{"无 assistant 消息不动",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"user","content":"u"}]}`},
		{"messages 缺失不动",
			`{"model":"deepseek-v4-flash"}`},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil)
			for _, rc := range assistantRC(t, out) {
				if rc != "<absent>" {
					t.Errorf("无 reasoning 痕迹却被加 reasoning_content=%q (out=%s)", rc, out)
				}
			}
		})
	}
}

// TestBackfillReasoningContentNonDeepSeek 非 deepseek 模型零改动:
// reasoning 字段保持原样,不新增 reasoning_content。
func TestBackfillReasoningContentNonDeepSeek(t *testing.T) {
	body := `{"model":"glm-5.2","messages":[

		{"role":"assistant","content":"a","reasoning":"thought"}]}`
	out := PrepareBodyOptWithEfforts([]byte(body), false, nil)
	for _, rc := range assistantRC(t, out) {
		if rc != "<absent>" {
			t.Errorf("非 deepseek 不应 backfill, got reasoning_content=%q (out=%s)", rc, out)
		}
	}
}

// TestBackfillReasoningContentBothFields 同时带 reasoning 与 reasoning_content:
// 以 reasoning_content 为准(不覆盖),reasoning 字段保留(兼容)——对齐客户端 matches 规则。
func TestBackfillReasoningContentBothFields(t *testing.T) {
	body := `{"model":"deepseek-v4-flash","messages":[

		{"role":"assistant","content":"a","reasoning":"t","reasoning_content":"existing"}]}`
	out := PrepareBodyOptWithEfforts([]byte(body), false, nil)
	got := assistantRC(t, out)
	if len(got) != 1 || got[0] != "existing" {
		t.Errorf("reasoning_content 应以已有值为准: got %v (out=%s)", got, out)
	}
	// 同时确认 reasoning 字段仍原样保留。
	var m map[string]any
	if err := json.Unmarshal(out, &m); err != nil {
		t.Fatal(err)
	}
	msgs, _ := m["messages"].([]any)
	first, _ := msgs[0].(map[string]any)
	if r, ok := first["reasoning"].(string); !ok || r != "t" {
		t.Errorf("reasoning 字段被改动: %v (out=%s)", first, out)
	}
}

// TestBackfillComposesWithInjectThinking backfill 与 injectThinking 组合语义:
// 显式 disabled 时 reasoning_effort 被删,但 backfill 的 hasTrace 半边照常生效
// (多轮一致性不因关思维链而丢);disabled + 零痕迹则零改动(thinkingEnabled 半边不亮)。
func TestBackfillComposesWithInjectThinking(t *testing.T) {
	// R6:disabled + 有痕迹 → 照补(复制 reasoning)。
	body := `{"model":"DEEPSEEK-v4-flash","thinking":{"type":"disabled"},"reasoning_effort":"high","messages":[

		{"role":"user","content":"u"},

		{"role":"assistant","content":"a","reasoning":"thought"}]}`
	out := PrepareBodyOptWithEfforts([]byte(body), false, nil)
	got := assistantRC(t, out)
	if len(got) != 1 || got[0] != "thought" {
		t.Errorf("disabled 时 backfill 仍应生效: got %v (out=%s)", got, out)
	}
	if typ, present := getThinkingType(t, out); !present || typ != "disabled" {
		t.Errorf("thinking.type 应保留 disabled, got %q present=%v", typ, present)
	}
	for _, k := range []string{"reasoning_effort", "reasoningEffort"} {
		if _, ok := objFieldString(t, out, k); ok {
			t.Errorf("%s 应被删除(disabled 时)", k)
		}
	}

	// R2:disabled + 零痕迹 → 零改动(新契约四分支之一)。
	body = `{"model":"DEEPSEEK-v4-flash","thinking":{"type":"disabled"},"messages":[

		{"role":"user","content":"u"},

		{"role":"assistant","content":"plain"}]}`
	out = PrepareBodyOptWithEfforts([]byte(body), false, nil)
	for _, rc := range assistantRC(t, out) {
		if rc != "<absent>" {
			t.Errorf("disabled+零痕迹 不应 backfill, got reasoning_content=%q (out=%s)", rc, out)
		}
	}
}

// TestBackfillZeroTraceThinkingEnabled issue #165 复现锚(R1):零痕迹多轮 deepseek,
// 经 L1 injectThinking 注入 enabled 后,thinkingEnabled 半边亮 → 每条 assistant
// 保证 reasoning_content 是 string(此处无 reasoning 可复制,全补空串)。
// 官方 ReasoningContentBackfillRule 的门控是 thinkingEnabled || hasTrace,
// 第三方客户端丢推理回传(零痕迹)形态下官方仍补,网关此前只移植了 hasTrace 半边。
func TestBackfillZeroTraceThinkingEnabled(t *testing.T) {
	body := `{"model":"deepseek-v4-flash","messages":[

		{"role":"user","content":"u1"},

		{"role":"assistant","content":"a1"},

		{"role":"user","content":"u2"},

		{"role":"assistant","content":"a2"},

		{"role":"user","content":"u3"}]}`
	out := PrepareBodyOptWithEfforts([]byte(body), false, nil)
	// 前置:出站确实是注入后的 enabled 形态(thinkingEnabled 判定读注入后请求体)。
	if typ, present := getThinkingType(t, out); !present || typ != "enabled" {
		t.Fatalf("thinking.type=%q present=%v want enabled (out=%s)", typ, present, out)
	}
	got := assistantRC(t, out)
	if len(got) != 2 {
		t.Fatalf("assistant 消息数 = %d want 2 (out=%s)", len(got), out)
	}
	for i, rc := range got {
		if rc != "" { // 既有 string(含空串)即满足;<absent>(键不存在)不满足
			t.Errorf("零痕迹 enabled 形态下 assistant[%d].reasoning_content = %q want 存在且为 \"\" (out=%s)", i, rc, out)
		}
	}
}

// TestBackfillNullAndNonStringNormalized issue #165 null/非 string 归一化(R3):
// 官方跳过条件是 "string"!=typeof reasoning_content 才动手——null/数字会被旧代码
// 的 if _, ok(键存在即跳过)当「已有」跳过,新契约归一化为 ""。
// 注意 hasTrace 半边:reasoning_content 键存在本身即痕迹,门控必然亮。
func TestBackfillNullAndNonStringNormalized(t *testing.T) {
	cases := []struct {
		name string
		body string
	}{
		{"reasoning_content:null 归一化为空串",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"assistant","content":"a","reasoning_content":null}]}`},
		{"reasoning_content:数字 归一化为空串",
			`{"model":"deepseek-v4-flash","messages":[

				{"role":"assistant","content":"a","reasoning_content":123}]}`},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil)
			got := assistantRC(t, out)
			if len(got) != 1 {
				t.Fatalf("assistant 消息数 = %d want 1 (out=%s)", len(got), out)
			}
			if got[0] != "" || got[0] == "<absent>" {
				t.Errorf("reasoning_content 应归一化为 \"\", got %q (out=%s)", got[0], out)
			}
		})
	}
}