File size: 10,061 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
// thinking.go DeepSeek 思维链开启:出站请求体注入 thinking:{type:"enabled"} + 默认档位。
//
// 根因(issue #43,Hermes 逆向官方客户端 codebuddy.js 已确认):
// 官方客户端对 deepseek 系模型标记 thinkingFormat:"deepseek" + requiresReasoningContentOnAssistantMessages,
// 发请求时「开思考」必须显式带 thinking:{type:"enabled"},否则上游默认按不思考应答
// (思维链不返回)。网关 payload 层此前完全不感知该字段,透传请求没有这个开关
// → 上游不给思维链;glm/kimi 走其他 thinkingFormat(qwen 系 enable_thinking 或默认开)所以正常。
//
// 打回修复(Hermes #43 验收实测):
//
//	thinking.type=enabled 单一字段不足——真实上游 deepseek-v4-flash 对「不带 reasoning_effort」的裸请求
//	仍然按不思考应答(reasoning_content 长度 0),带 reasoning_effort:high 才有思维链。
//	逆向 codebuddy.js 证实:isThinkingEnabled = !!(reasoning_summary || reasoning_effort || reasoning?.effort),
//	case "deepseek" 的 enabled 分支在实际出站里同时保留 reasoning_effort,官方「开思考」= thinking.type:enabled
//	+ 某档 effort;默认档来自 reasoning.defaultEffort ?? 兜底 "high"(configure thinking 无来源时 warn fallback to 'high')。
//
// 行为对齐官方客户端(两路组合):
//   - thinking.type 已显式 enabled / disabled → 客户端显式控制,绝不覆盖;disabled 时照抄 case 行为
//     删 reasoning_effort(snake/camel 双字段)。enabled 但缺 effort → 补默认档(官方 configure 行为)。
//   - 无 thinking / thinking.type 空 / 已有 reasoning_effort → 注入 {type:"enabled"} + 补默认档。
//   - 显式 reasoning_effort 一律不覆盖、不降级(降级交给 payload.go normalizeReasoningEffort)。
//   - 非 deepseek 模型(glm/kimi/qwen 等)→ 零改动。
package upstream

import (
	"strings"
)

// defaultDeepSeekEffort 官方客户端默认档兜底(configure thinking 无来源时 warn fallback to 'high',
// REASONING_SUPPLEMENTS.defaultEffort 亦为 "high")。补入后走 normalizeReasoningEffort 降级管线,
// 模型不支持 high 时自动落到 ≤high 的最高支持档。
const defaultDeepSeekEffort = "high"

// lookupDefaultEffort 从 FetchModels 缓存的 defaultEfforts 表按模型名查默认档。
// nil map 或模型未缓存 → 空串(thinking.go 回退硬编码 high)。
// 键为模型 ID 原样(与 efforts 缓存对齐:normalizeReasoningEffort 精确匹配 model)。
func lookupDefaultEffort(defaultEfforts map[string]string, model string) string {
	if len(defaultEfforts) == 0 || model == "" {
		return ""
	}
	return defaultEfforts[model]
}

// isDeepSeekModel 模型名以 deepseek 为前缀(不区分大小写)。
// 覆盖 deepseek-v4.1-flash / deepseek-v4-pro / deepseek-r1 等变体;
// 前缀匹配对齐官方 thinkingFormat:"deepseek" 的判定口径,避免漏注。
func isDeepSeekModel(model string) bool {
	return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "deepseek")
}

// backfillReasoningContent DeepSeek 多轮一致性:保证每条 assistant 消息带
// reasoning_content 字段且值为 string——即 requiresReasoningContentOnAssistantMessages
// (官方客户端 matches 规则,issue #165 对齐官方 apply 门控)。
//
// 门控(对齐官方 ReasoningContentBackfillRule:thinkingEnabled || hasTrace),
// thinkingEnabled 取自注入后请求体的 thinking.type == "enabled"(injectThinking 先行,
// payload.go 管线顺序已保证;本网关对 deepseek 无条件注入 enabled,等价于非 disabled 一律补):
//   - 非 deepseek 模型 → 零改动(isDeepSeekModel 闸不动)。
//   - deepseek + enabled(含 L1 注入后)→ 每条 assistant 保证 reasoning_content 是
//     string:已有 string 原样保留(不覆盖);reasoning 是非空 string 且 rc 非 string →
//     复制 reasoning 值;两者皆无 → 补空串 ""。第三方客户端丢推理回传(零痕迹)形态
//     下官方本就补,网关此前只移植了 hasTrace 半边(issue #165 修复点)。
//   - deepseek + disabled + 无痕迹 → 零改动(官方 thinkingEnabled=false 且 ec=false → 不补)。
//   - deepseek + disabled + 有痕迹 → 照补(官方 hasTrace 半边,双方一致)。
//
// 归一化对齐官方 "string"!=typeof 语义:reasoning_content 为 null/数字等非 string
// 值时不算「已有」,落补 ""/复制分支(旧代码键存在即跳过,null 会被当「已有」漏补)。
// hasTrace = 会话内任一消息带非空 reasoning(string)或已有 reasoning_content 字段
// (比官方仅扫 assistant 的口径宽,只影响 disabled 分支,装饰性差异)。
func backfillReasoningContent(obj map[string]any) {
	model, _ := obj["model"].(string)
	if !isDeepSeekModel(model) {
		return
	}
	msgs, ok := obj["messages"].([]any)
	if !ok || len(msgs) == 0 {
		return
	}
	// thinkingEnabled 半边:读注入后的 thinking.type(与官方 el.thinkingEnabled 对应)。
	thinkingEnabled := false
	if th, ok := obj["thinking"].(map[string]any); ok {
		if typ, _ := th["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "enabled") {
			thinkingEnabled = true
		}
	}
	// hasTrace 半边:检测是否有任何 reasoning 痕迹(非空 reasoning 或已有 reasoning_content)。
	hasTrace := false
	for _, mm := range msgs {
		msg, ok := mm.(map[string]any)
		if !ok {
			continue
		}
		if r, ok := msg["reasoning"].(string); ok && r != "" {
			hasTrace = true
			break
		}
		if _, ok := msg["reasoning_content"]; ok {
			hasTrace = true
			break
		}
	}
	if !thinkingEnabled && !hasTrace {
		return
	}
	// 第二遍:所有 assistant 消息补/复制 reasoning_content 字段,并镜像保证
	// reasoning 字段存在且非空(issue #165 追评——部分账号/租户对 thinking 形态
	// 校验 len(reasoning)>0:缺失/null/空串 400,空白串 200;官方 CLI 本就给
	// assistant 挂上一轮 reasoning 文本,见 itemsToMessages 的 applyPendingReasoning)。
	// 跳过条件只认 string(官方 "string"!=typeof 才动手):null/数字归一化。
	//   - reasoning 已是非空 string → 不动;
	//   - rc 是非空 string → 镜像写入 rc 值(两字段最终都存在且非空);
	//   - 两者皆无/皆空 → 补单个空格 " "(上游 len>0 不 trim:空白串过闸、空串
	//     不过——空白串占位有官方 Moonshot 规则 "-" 同款先例,且对模型上下文
	//     无语义影响:该字段是透传校验位非内容消费位)。
	for _, mm := range msgs {
		msg, ok := mm.(map[string]any)
		if !ok {
			continue
		}
		role, _ := msg["role"].(string)
		if role != "assistant" {
			continue
		}
		rc, hasRC := msg["reasoning_content"].(string)
		if hasRC {
			// rc 已有 string → 不覆盖(原有语义保留)。
		} else if r, ok := msg["reasoning"].(string); ok {
			rc = r
			msg["reasoning_content"] = rc
		} else {
			rc = ""
			msg["reasoning_content"] = rc
		}
		// 镜像:reasoning 缺失/null/空串 → 归一化(非空 rc 优先,皆无补 " ")。
		if r, ok := msg["reasoning"].(string); ok && r != "" {
			continue // 已非空 → 不覆盖
		}
		if rc != "" {
			msg["reasoning"] = rc
		} else {
			msg["reasoning"] = " "
		}
	}
}

// injectThinking 按 DeepSeek 思维链开关规则改写请求体。非 deepseek 零改动。
//
// 核心逻辑(对齐官方客户端):
//   - 「开思考」必须 thinking.type=enabled + 有 effort 档位(Hermes #43 打回证据)。
//   - 显式 thinking.type 非空 → 客户端显式控制:enabled 缺 effort 时补默认档;
//     disabled 尊重并删 reasoning_effort(snake/camel 双字段)。
//   - 无 thinking / type 空 / 已有 effort → 注入 enabled 并补默认档(已有 effort 不覆盖)。
//
// defaultEffort 为该模型声明的默认档(来自 FetchModels 缓存 reasoning.defaultEffort);
// 空串时回退硬编码 defaultDeepSeekEffort(向后兼容)。
func injectThinking(obj map[string]any, defaultEffort string) {
	model, _ := obj["model"].(string)
	if !isDeepSeekModel(model) {
		return
	}
	th, ok := obj["thinking"].(map[string]any)
	typ := ""
	if ok {
		typ, _ = th["type"].(string)
		typ = strings.TrimSpace(typ)
	}
	// 显式控制分支:type 非空(enabled/disabled 均为明确意图)→ 不改 type。
	if typ != "" {
		if strings.EqualFold(typ, "disabled") {
			delete(obj, "reasoning_effort")
			delete(obj, "reasoningEffort")
			return // disabled:关思考且不带任何 effort(照抄客户端 case 行为)
		}
		ensureDeepSeekEffort(obj, defaultEffort) // 显式 enabled 缺 effort → 补默认档
		return
	}
	// 无 thinking(或 thinking 非法非对象值)或 thinking 对象 type 缺失/为空:
	// 注入 enabled(客户端 case "deepseek" 行为)。有 reasoning_effort 也走此分支
	// (effort 保留给既有降级逻辑,开关照开)。
	if !ok {
		obj["thinking"] = map[string]any{"type": "enabled"}
	} else {
		th["type"] = "enabled"
	}
	ensureDeepSeekEffort(obj, defaultEffort)
}

// ensureDeepSeekEffort 缺 effort 档位时补默认档(snake 优先,camel 兜底)。
// 已有任一 effort → 不覆盖(显式档位不做任何改写,降级交给 normalizeReasoningEffort)。
// defaultEffort 空串 → 回退 defaultDeepSeekEffort(硬编码 "high")。
func ensureDeepSeekEffort(obj map[string]any, defaultEffort string) {
	_, hasSnake := obj["reasoning_effort"]
	if hasSnake {
		return
	}
	_, hasCamel := obj["reasoningEffort"]
	if hasCamel {
		return
	}
	if defaultEffort == "" {
		defaultEffort = defaultDeepSeekEffort
	}
	obj["reasoning_effort"] = defaultEffort
}