File size: 17,399 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 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 | // payload.go 改写发往上游的 chat 请求体:
// 1. 强制 stream:true(上游拒绝非流式)
// 2. tool_choice 归一化(上游该字段是 string,对象形式会 400 code=11101)
// 3. image_url 归一化(上游只认 OpenAI 对象形态,字符串会 400 code=11101)
package upstream
import (
"encoding/json"
"log"
"strings"
)
// PrepareBodyOpt 单 pass 改写;sanitize=false 时行为完全还原(仅强制 stream + 归一化 tool_choice)。
func PrepareBodyOpt(src []byte, sanitize bool) []byte {
return PrepareBodyOptWithEffortsAndDefault(src, sanitize, nil, nil)
}
// PrepareBodyOptWithEfforts 在 PrepareBodyOpt 基础上按模型 supportedEfforts 降级 reasoning_effort:
// 仅当请求显式携带且模型不支持该档位时,改为 ≤请求档位的最高支持档;支持档全部高于请求档时取最低档;
// 未知模型/未知档位/未携带该字段一律透传。efforts 为 nil 表示未知(不降级)。
//
// 向后兼容封装:不传 defaultEfforts(无模型声明默认档),thinking.go 回退硬编码 high。
func PrepareBodyOptWithEfforts(src []byte, sanitize bool, efforts map[string][]string) []byte {
return PrepareBodyOptWithEffortsAndDefault(src, sanitize, efforts, nil)
}
// PrepareBodyOptWithEffortsAndDefault 完整管线:efforts 降级 + thinking.go 按
// defaultEfforts(模型声明默认档)补档。defaultEfforts 为 nil 时与旧行为一致
// (deepseek 缺档回退硬编码 high)。
func PrepareBodyOptWithEffortsAndDefault(src []byte, sanitize bool, efforts map[string][]string, defaultEfforts map[string]string) []byte {
if len(src) == 0 {
return src
}
var obj map[string]any
if err := json.Unmarshal(src, &obj); err != nil {
return src
}
obj["stream"] = true
// max_completion_tokens → max_tokens 翻译(吸收上游 PR #116,Closes #117):
// OpenAI 规范里 max_tokens 已 deprecated、max_completion_tokens 是新字段;
// DeepSeek Harness 等新客户端只发别名。WorkBuddy 上游(CN /v2 与 global
// /console 同源)只认 max_tokens——别名透传会被上游忽略后回落默认输出上限
// (实测 32000),长流任务被截。
translateMaxCompletionTokens(obj)
clampGPTMinMaxTokens(obj)
// stream_options 仅当 body 未显式带时补 {include_usage: true}(D7):
// 官方 CLI 流式必发该字段,上游据此在末帧返回 usage 用量;显式带则不覆盖。
if _, has := obj["stream_options"]; !has {
obj["stream_options"] = map[string]any{"include_usage": true}
}
normalizeToolChoice(obj)
normalizeToolPatterns(obj)
normalizeRoles(obj)
normalizeImageURL(obj)
// tool 配对三步(见 tool_pairing.go):先合并再重排再清理。所有模型一律执行(独立于
// deepseek-only 的 sanitize 开关)。这是「让请求通过」的安全网——不完整配对的
// tool_calls/tool 结果会让上游对之后每条消息都返 400,必须先行剔除;
// 插在结果中间的非 tool 消息(Codex image_resize_notice)同样判配对断裂,
// 先 repack 挪后,再 cleanup 删孤儿,两侧同口径。
//
// 顺序不能换:mergeAdjacentToolCalls 必须最先跑——它把「背靠背的两条
// assistant.tool_calls」合成一条(部分 agent 客户端回放并行调用的报文形状),是上游
// deepseek 系模型 11148 的正面修复;先合并再 repack,repack 才看得到完整的一批调用。
if msgs, ok := obj["messages"].([]any); ok {
msgs, _ = mergeAdjacentToolCalls(msgs)
msgs, _ = repackToolResultBlocks(msgs)
msgs, _ = cleanupOrphanToolCalls(msgs)
// 无改动时两步都返回原 slice,这里回写等于零操作;任一步重排/删除
// (哪怕后续步骤零改动)也必须落到 obj——不能只在「最后一步改动」时回写,
// 否则 repack 单独生效的结果会被原 slice 覆盖丢失。
obj["messages"] = msgs
}
// DeepSeek 思维链开关(见 thinking.go):注入 thinking.type=enabled + 缺档补默认档。
// 先于 normalizeReasoningEffort 执行:补入的默认档也要走既有降级管线,
// 模型不支持默认档时自动落到 ≤ 默认档的最高支持档(不出站不合规档位)。
modelName, _ := obj["model"].(string)
injectThinking(obj, lookupDefaultEffort(defaultEfforts, modelName))
normalizeReasoningEffort(obj, efforts)
// DeepSeek 多轮一致性:assistant 消息带 reasoning 痕迹时回填 reasoning_content
// (requiresReasoningContentOnAssistantMessages,见 thinking.go)。
backfillReasoningContent(obj)
if sanitize {
if msgs, ok := obj["messages"].([]any); ok {
sanitizeMessages(msgs)
}
}
out, err := json.Marshal(obj)
if err != nil {
return src
}
return out
}
// translateMaxCompletionTokens 把 OpenAI 别名 max_completion_tokens 翻译为上游
// 认的 max_tokens(吸收上游 PR #116)。规则:显式 max_tokens 优先(别名只删);
// 别名非正数值(0/null/负数)不翻译(0/null 语义是「未设置」,负数是非法值,
// 翻译等于把垃圾搬进 max_tokens);非数值别名(字符串等畸形)不翻译(原样
// 透传由上游报 11101 参数错)。两域同口径:CN /v2 与 global /console 是同一套
// API,翻译不分 realm。
func translateMaxCompletionTokens(obj map[string]any) {
alias, has := obj["max_completion_tokens"]
delete(obj, "max_completion_tokens") // 无论翻译与否,别名一律删(减少 body 体积与排障噪音)
if !has {
return
}
if _, explicit := obj["max_tokens"]; explicit {
return // 显式 max_tokens 优先:别名只删不译
}
// json.Unmarshal 数字 → float64(整数去整后回写,避免 1.28e5 科学计数法/小数
// 尾巴进上游 body);其他数值类型防御性兼容(int 家族——手构造 map 的调用方)。
switch v := alias.(type) {
case float64:
if v > 0 && v == float64(int64(v)) {
obj["max_tokens"] = int64(v)
}
case int64:
if v > 0 {
obj["max_tokens"] = v
}
case int:
if v > 0 {
obj["max_tokens"] = int64(v)
}
}
}
// gptMinMaxTokens GPT 系上游接受的 max_tokens 下限。
const gptMinMaxTokens = 16
// clampGPTMinMaxTokens 把 GPT 系模型过小的 max_tokens 抬到下限。
//
// 背景:上游 GPT 系(实测 gpt-6-sol / gpt-6-luna / gpt-5.6-sol)对 max_tokens < 16
// 一律 400 code=11133 model_param_invalid(15 拒、16 过,同号同 body 对照);hy4 等
// 非 GPT 模型无此限制。Claude Code 切模型时发 max_tokens 极小的探针,全号轮转同样
// 被拒 → 客户端 503,模型永远切不过去。账号与 body 其余部分无关,换号无用,只能
// 在发送前修。抬到下限只放宽输出上限、不改语义;未携带字段 / 非数值 / 已达下限一律不动。
func clampGPTMinMaxTokens(obj map[string]any) {
model, _ := obj["model"].(string)
if !strings.Contains(strings.ToLower(model), "gpt-") {
return
}
var v int64
switch n := obj["max_tokens"].(type) {
case float64:
v = int64(n)
case int64:
v = n
case int:
v = int64(n)
default:
return
}
if v < gptMinMaxTokens {
obj["max_tokens"] = int64(gptMinMaxTokens)
log.Printf("max_tokens clamped model=%s %d -> %d", model, v, gptMinMaxTokens)
}
}
// effortRank 档位从低到高。
var effortRank = map[string]int{"off": 0, "minimal": 1, "low": 2, "medium": 3, "high": 4, "xhigh": 5, "max": 6}
// normalizeReasoningEffort 按模型 supportedEfforts 降级 reasoning_effort(snake/camel 双字段兼容)。
// - 请求档位模型支持 → 原样透传
// - 请求档位不支持 → 改为 ≤请求档位的最高支持档(降级)
// - 支持档全部高于请求档 → 取最低支持档(偏离最小)
// - 未知模型/未知档位/未携带字段/模型未缓存 → 一律透传
func normalizeReasoningEffort(obj map[string]any, efforts map[string][]string) {
if len(efforts) == 0 {
return
}
model, _ := obj["model"].(string)
if model == "" {
return
}
supported, ok := efforts[model]
if !ok || len(supported) == 0 {
return
}
key := ""
if _, present := obj["reasoning_effort"]; present {
key = "reasoning_effort"
} else if _, present := obj["reasoningEffort"]; present {
key = "reasoningEffort"
} else {
return
}
reqStr, ok := obj[key].(string)
if !ok {
return
}
reqStr = strings.TrimSpace(strings.ToLower(reqStr))
reqIdx, known := effortRank[reqStr]
if !known {
return
}
// 在 ≤请求档位的支持档里选最高档;命中且与请求不同才改写。
best, bestIdx := "", -1
for _, s := range supported {
idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))]
if k && idx <= reqIdx && idx > bestIdx {
best, bestIdx = s, idx
}
}
if best != "" {
if !strings.EqualFold(best, reqStr) {
obj[key] = best
log.Printf("reasoning_effort downgraded model=%s %s -> %s", model, reqStr, best)
}
return
}
// 支持档全部高于请求档:取最低支持档。
lowest, lowestIdx := "", 1<<30
for _, s := range supported {
idx, k := effortRank[strings.TrimSpace(strings.ToLower(s))]
if k && idx < lowestIdx {
lowest, lowestIdx = s, idx
}
}
if lowest != "" {
obj[key] = lowest
log.Printf("reasoning_effort floored model=%s %s -> %s", model, reqStr, lowest)
}
}
// normalizeRoles 把 messages 里的 developer 角色归一为 system。
//
// 背景:上游对 messages 的 role 字段做白名单校验,developer 不在白名单内,
// 命中即 HTTP 400 code=11128。developer 是 OpenAI 新规范里 system 的别名
// (Codex / Cursor 等新客户端用它承载 system 级指令),改写为 system 不丢语义。
//
// 此归一化是「协议兼容」(补上游 role 白名单),不是「内容脱敏」,
// 因此有意与 SanitizeFingerprints / sanitize 参数解耦:即使 sanitize=false 也照常归一。
//
// 只认 developer 这一个值:其余 role(system/user/assistant/tool/任意未知值)一律原样保留,
// 不合并、不重排、不删除任何消息(上游对多 system 的行为尚未实测,合并会引入新变量)。
func normalizeRoles(obj map[string]any) {
msgs, ok := obj["messages"].([]any)
if !ok {
return
}
for i, m := range msgs {
msg, ok := m.(map[string]any)
if !ok {
continue
}
role, ok := msg["role"].(string)
if !ok {
continue
}
if strings.EqualFold(strings.TrimSpace(role), "developer") {
msg["role"] = "system"
log.Printf("role normalized developer->system idx=%d", i)
}
}
}
// normalizeImageURL 兼容 OpenAI chat 多模态内容的两种 image_url 写法。
//
// OpenAI Chat Completions 规范使用对象形态 {"url":"...","detail":"..."},
// 部分客户端(以及 Responses -> Chat 转换器)会发送字符串形态 "data:..." 或
// "https://..."。WorkBuddy 上游只接受对象形态,字符串会返回 400 code=11101
// "cannot unmarshal string into ... ImageContent"。
//
// 这里只做形状转换:字符串转 {"url": 原值};已有对象及其中 url/detail/mime_type
// 原样保留;空字符串、缺失值、对象内非法 url 一律不补默认值,让上游返回真实错误。
func normalizeImageURL(obj map[string]any) {
msgs, ok := obj["messages"].([]any)
if !ok {
return
}
for _, rawMsg := range msgs {
msg, ok := rawMsg.(map[string]any)
if !ok {
continue
}
parts, ok := msg["content"].([]any)
if !ok {
continue
}
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok || part["type"] != "image_url" {
continue
}
imageURL, ok := part["image_url"].(string)
if !ok || imageURL == "" {
continue
}
part["image_url"] = map[string]any{"url": imageURL}
}
}
}
// ensureConsoleSystem global realm 兜底 system 注入(吸收 PR #45,防 console 域上游 code 11-128):
// 首条消息非 system 时在 messages 最前补一条 fallback system("You are a helpful assistant.")。
// 仅对 global 请求调用(CN 现状不动;即使首条就是 system 也不重复注入)。
// body 不可解析时原样返回(与 prepareBody 语义一致:坏 body 不在这里二次错误化)。
func ensureConsoleSystem(body []byte) []byte {
if len(body) == 0 {
return body
}
var obj map[string]any
if err := json.Unmarshal(body, &obj); err != nil {
return body
}
msgs, ok := obj["messages"].([]any)
if !ok || len(msgs) == 0 {
return body
}
first, ok := msgs[0].(map[string]any)
if ok {
if role, _ := first["role"].(string); strings.EqualFold(strings.TrimSpace(role), "system") {
return body // 首条已是 system:不注入
}
}
obj["messages"] = append([]any{map[string]any{"role": "system", "content": "You are a helpful assistant."}}, msgs...)
out, err := json.Marshal(obj)
if err != nil {
return body
}
return out
}
// normalizeToolChoice 按上游 Go struct(string 类型)改写 OpenAI tool_choice。
// - "none" → 删 tool_choice + 删 tools/functions
// - {"type":"none"} → 同上
// - {"type":"auto"/"required"} → 字符串 "auto"/"required"
// - {"type":"function","function":{"name":"x"}} → 字符串 "x"
// - 其他对象/非标量 → 删 tool_choice
func normalizeToolChoice(obj map[string]any) {
suppress := func() {
delete(obj, "tools")
delete(obj, "functions")
}
tc, present := obj["tool_choice"]
if !present {
return
}
switch v := tc.(type) {
case string:
if strings.EqualFold(strings.TrimSpace(v), "none") {
delete(obj, "tool_choice")
suppress()
}
case map[string]any:
typ, _ := v["type"].(string)
typ = strings.ToLower(strings.TrimSpace(typ))
switch typ {
case "none":
delete(obj, "tool_choice")
suppress()
case "auto", "required":
obj["tool_choice"] = typ
case "function":
name := ""
if fn, ok := v["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
}
if name == "" {
name, _ = v["name"].(string)
}
if name = strings.TrimSpace(name); name != "" {
obj["tool_choice"] = name
} else {
obj["tool_choice"] = "auto"
}
default:
delete(obj, "tool_choice")
}
default:
delete(obj, "tool_choice")
}
}
// normalizeToolPatterns 归一化 tools 子树里 pattern 的非标准转义 `\_`(→ `_`)。
//
// 上游对 tools[].function.parameters 做严格 JSON Schema/正则文法校验,pattern 含
// `\_`(转义的字面量下划线)会整体拒收:400 code=11129 invalid_function_call_
// parameters(displayMsg「工具定义不合规」)。`\_` 不是任何正则文法的合法转义,
// 但所有主流引擎(RE2/PCRE/JS Annex B)都宽容地视为 `_` 本身——上游校验器比它们
// 全部更严(对照 V8 严格文法 u 标志,唯一同样拒绝的实现)。实案:ZCode 的 exa 插件
// agent_run 工具 runId/previousRunId 带 `^agent\_run\_`,deepseek 系全家确定性 400
// → 网关侧归 ErrClient 只换号不罚但喂连败计数 → 轮转烧满 5 连败触发连败降权、
// 客户端 503(2026-09-29/30 两次实案)。schema 级拒绝换账号无用,只能在发送前修。
//
// 归一无损:`\_` 与 `_` 在所有引擎匹配语义相同(各引擎实测 + 上游对照探针:归一后
// 200),工具方功能不变。只动 tools 子树(pattern 值 + patternProperties 键);
// 消息正文里的 `\_`(如 Windows 路径 C:\_x)不碰。其余非标转义(`\:` 等)未证实
// 触发,不扩面——有实案再议。独立于 sanitize 开关:这是「让请求通过」,不是脱敏。
func normalizeToolPatterns(obj map[string]any) {
rawTools, ok := obj["tools"].([]any)
if !ok {
return
}
for _, raw := range rawTools {
tool, ok := raw.(map[string]any)
if !ok {
continue
}
// OpenAI 形态 tools[].function.parameters;裸 tools[].parameters 兼容。
if fn, ok := tool["function"].(map[string]any); ok {
unescapePatternLiteralEscapes(fn["parameters"])
}
unescapePatternLiteralEscapes(tool["parameters"])
}
}
// unescapePatternLiteralEscapes 递归改写 schema 树里 pattern 值与 patternProperties
// 键中的 `\_` → `_`(patternProperties 的键也是正则;map 键不可原地改,命中时重建
// 该层)。
func unescapePatternLiteralEscapes(node any) {
switch n := node.(type) {
case map[string]any:
if p, ok := n["pattern"].(string); ok && strings.Contains(p, `\_`) {
n["pattern"] = strings.ReplaceAll(p, `\_`, `_`)
}
if props, ok := n["patternProperties"].(map[string]any); ok {
rebuilt := false
fixed := make(map[string]any, len(props))
for k, v := range props {
if strings.Contains(k, `\_`) {
k = strings.ReplaceAll(k, `\_`, `_`)
rebuilt = true
}
fixed[k] = v
}
if rebuilt {
n["patternProperties"] = fixed
}
}
for _, v := range n {
unescapePatternLiteralEscapes(v)
}
case []any:
for _, v := range n {
unescapePatternLiteralEscapes(v)
}
}
}
|