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