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)
}
})
}
}
|