Download internal/upstream/payload.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/payload.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/internal/upstream/payload.go
-
curl -L -o payload.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/upstream/payload.go
17.4 kB
| // 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) | |
| } | |
| } | |
| } | |