gcli2api / internal /upstream /payload_test.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
19.7 kB
package upstream
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
// TestNormalizeRoles 验证出站请求体把 developer 角色归一为 system。
// 上游 role 白名单不含 developer(OpenAI 新规范的 system 别名),
// 命中即 HTTP 400 code=11128;此处走 PrepareBodyOptWithEfforts 全链路断言。
func TestNormalizeRoles(t *testing.T) {
cases := []struct {
name string
body string
wantRoles []string // 与输出 messages 逐条对应的期望 role;len 即消息数
}{
{"developer 改写为 system",
`{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}},
{"Developer 首字母大写改写",
`{"messages":[{"role":"Developer","content":"x"}]}`, []string{"system"}},
{"DEVELOPER 全大写改写",
`{"messages":[{"role":"DEVELOPER","content":"x"}]}`, []string{"system"}},
{"前后空白 TrimSpace 后改写",
`{"messages":[{"role":" developer ","content":"x"}]}`, []string{"system"}},
{"system 原样保留",
`{"messages":[{"role":"system","content":"x"}]}`, []string{"system"}},
{"user 原样保留",
`{"messages":[{"role":"user","content":"x"}]}`, []string{"user"}},
{"assistant 原样保留",
`{"messages":[{"role":"assistant","content":"x"}]}`, []string{"assistant"}},
{"tool 原样保留(不因未知而改写)",
`{"messages":[{"role":"tool","content":"x"}]}`, []string{"tool"}},
{"messages 缺失不 panic 且其余字段不变",
`{"model":"glm-5.2"}`, []string{}},
{"messages 为空数组不 panic",
`{"messages":[]}`, []string{}},
{"混合消息仅 developer 被改写",
`{"messages":[{"role":"developer","content":"a"},{"role":"user","content":"b"},{"role":"developer","content":"c"}]}`,
[]string{"system", "user", "system"}},
{"sanitize=false 时仍归一(与脱敏解耦)",
`{"messages":[{"role":"developer","content":"x"}]}`, []string{"system"}},
{"非对象消息元素跳过、其余正常处理",
`{"messages":["str",{"role":"developer","content":"x"},42]}`, []string{"system"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// 全程 sanitize=false:验证 role 归一与内容脱敏开关无关(D4)。
out := PrepareBodyOptWithEfforts([]byte(c.body), false, nil)
var obj map[string]any
if err := json.Unmarshal(out, &obj); err != nil {
t.Fatalf("unmarshal: %v (out=%s)", err, out)
}
// 提取输出 messages 里的 role(非对象元素跳过,不 panic)。
var got []string
if msgs, ok := obj["messages"].([]any); ok {
for _, m := range msgs {
msg, ok := m.(map[string]any)
if !ok {
continue
}
if role, ok := msg["role"].(string); ok {
got = append(got, role)
}
}
}
if len(got) != len(c.wantRoles) {
t.Fatalf("role 数量不符: got %v (%d) want %v (%d)", got, len(got), c.wantRoles, len(c.wantRoles))
}
for i := range got {
if got[i] != c.wantRoles[i] {
t.Errorf("role[%d] = %q want %q", i, got[i], c.wantRoles[i])
}
}
})
}
// messages 缺失时,其余字段必须原样保留(除强制 stream)。
t.Run("messages 缺失时其余字段不变", func(t *testing.T) {
out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","temperature":0.7}`), false, nil)
var obj map[string]any
if err := json.Unmarshal(out, &obj); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if obj["model"] != "glm-5.2" || obj["temperature"] != 0.7 {
t.Errorf("其余字段被改动: %v", obj)
}
})
}
func TestPrepareBodyOptWithEfforts(t *testing.T) {
efforts := map[string][]string{
"glm-5.2": {"off", "low", "high"},
"glm-5.2-mini": {"low", "medium"},
"glm-5.2-max": {"high", "xhigh"},
}
cases := []struct {
name string
body string
efforts map[string][]string
wantKey string // 输出应带有的 effort 字段名;空表示该字段应不存在
wantVal string // 期望值
}{
{"downgrade to highest supported at or below request",
`{"model":"glm-5.2-mini","reasoning_effort":"high"}`, efforts, "reasoning_effort", "medium"},
{"floor to lowest when all supported above request",
`{"model":"glm-5.2-max","reasoning_effort":"low"}`, efforts, "reasoning_effort", "high"},
{"supported effort passes through unchanged",
`{"model":"glm-5.2","reasoning_effort":"low"}`, efforts, "reasoning_effort", "low"},
{"camelCase field name downgrades and keeps key",
`{"model":"glm-5.2-mini","reasoningEffort":"high"}`, efforts, "reasoningEffort", "medium"},
{"unknown model passes through",
`{"model":"unknown","reasoning_effort":"max"}`, efforts, "reasoning_effort", "max"},
{"unknown effort value passes through",
`{"model":"glm-5.2","reasoning_effort":"ultra"}`, efforts, "reasoning_effort", "ultra"},
{"empty cache passes through",
`{"model":"glm-5.2","reasoning_effort":"max"}`, map[string][]string{}, "reasoning_effort", "max"},
{"no effort field untouched",
`{"model":"glm-5.2-mini","messages":[]}`, efforts, "", ""},
{"nil efforts map passes through",
`{"model":"glm-5.2","reasoning_effort":"max"}`, nil, "reasoning_effort", "max"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out := PrepareBodyOptWithEfforts([]byte(c.body), false, c.efforts)
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal: %v (body=%s)", err, out)
}
if c.wantKey == "" {
if _, ok := m["reasoning_effort"]; ok {
t.Errorf("reasoning_effort should be absent, got %v", m["reasoning_effort"])
}
if _, ok := m["reasoningEffort"]; ok {
t.Errorf("reasoningEffort should be absent, got %v", m["reasoningEffort"])
}
return
}
got, ok := m[c.wantKey].(string)
if !ok || got != c.wantVal {
t.Errorf("%s: got %v (%T) want %q", c.wantKey, m[c.wantKey], m[c.wantKey], c.wantVal)
}
})
}
}
// TestPrepareBodyStreamOptions body 未显式带 stream_options 时注入
// {include_usage: true}(D7,官方 CLI 流式必发);body 已带则不覆盖。
func TestPrepareBodyStreamOptions(t *testing.T) {
out := PrepareBodyOptWithEfforts([]byte(`{"model":"glm-5.2","messages":[]}`), false, nil)
var obj map[string]any
if err := json.Unmarshal(out, &obj); err != nil {
t.Fatalf("unmarshal: %v (out=%s)", err, out)
}
so, ok := obj["stream_options"].(map[string]any)
if !ok {
t.Fatalf("stream_options not injected: %v", obj["stream_options"])
}
if so["include_usage"] != true {
t.Errorf("stream_options.include_usage = %v want true", so["include_usage"])
}
out2 := PrepareBodyOptWithEffertsPreserve(t, `{"model":"glm-5.2","messages":[],"stream_options":{"include_usage":false}}`)
obj2, err := decodeBody(out2)
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
so2, ok := obj2["stream_options"].(map[string]any)
if !ok {
t.Fatalf("stream_options lost: %v", obj2["stream_options"])
}
if so2["include_usage"] != false {
t.Errorf("stream_options.include_usage = %v want false (not overwritten)", so2["include_usage"])
}
}
// PrepareBodyOptWithEffertsPreserve helper:PrepareBodyOptWithEfforts 包装。
func PrepareBodyOptWithEffertsPreserve(t *testing.T, body string) []byte {
t.Helper()
return PrepareBodyOptWithEfforts([]byte(body), false, nil)
}
// decodeBody helper:解析 body JSON。
func decodeBody(b []byte) (map[string]any, error) {
var obj map[string]any
err := json.Unmarshal(b, &obj)
return obj, err
}
// TestPrepareBodyDeterministic 序列化稳定性:同输入跑多遍出站字节级一致
// (prompt_cache_key 前缀命中的前提——链中不得注入时间/随机/ID 类不确定源)。
func TestPrepareBodyDeterministic(t *testing.T) {
inputs := []string{
`{"model":"glm-5.2","messages":[{"role":"system","content":"你是助手"},{"role":"user","content":"你好"}],"reasoning_effort":"high"}`,
`{"model":"deepseek-v4","messages":[{"role":"user","content":"写个函数"}],"tool_choice":{"type":"auto"},"tools":[{"type":"function","function":{"name":"f"}}]}`,
`{"model":"glm-5.3","messages":[{"role":"developer","content":"sys"},{"role":"user","content":[{"type":"text","text":"hi"}]}]}`,
}
for i, in := range inputs {
var first []byte
for round := 0; round < 5; round++ {
out := PrepareBodyOptWithEfforts([]byte(in), true, map[string][]string{"glm-5.2": {"off", "low", "high"}})
if round == 0 {
first = out
continue
}
if string(out) != string(first) {
t.Fatalf("input #%d round %d differs from round 0:\n%s\n%s", i, round, first, out)
}
}
}
}
// TestNormalizeImageURL 覆盖 OpenAI chat 多模态内容的 image_url 兼容:
// 字符串形态必须转为上游需要的对象形态;对象形态及其中字段必须原样保留;
// 无效输入不补默认值,继续交给上游返回真实错误。
func TestNormalizeImageURL(t *testing.T) {
tests := []struct {
name string
body string
want any
}{
{
name: "data url string to object",
body: `{"messages":[{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":"data:image/png;base64,QUJD"}]}]}`,
want: map[string]any{"url": "data:image/png;base64,QUJD"},
},
{
name: "http url string to object",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":"https://example.test/a.png"}]}]}`,
want: map[string]any{"url": "https://example.test/a.png"},
},
{
name: "object with detail preserved",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD","detail":"low","mime_type":"image/png"}}]}]}`,
want: map[string]any{"url": "data:image/png;base64,QUJD", "detail": "low", "mime_type": "image/png"},
},
{
name: "invalid object url type preserved",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":123}}]}]}`,
want: map[string]any{"url": float64(123)},
},
{
name: "missing image url preserved",
body: `{"messages":[{"role":"user","content":[{"type":"image_url"}]}]}`,
want: nil,
},
{
name: "empty string preserved",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":""}]}]}`,
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, sanitize := range []bool{false, true} {
out := PrepareBodyOptWithEfforts([]byte(tc.body), sanitize, nil)
obj, err := decodeBody(out)
if err != nil {
t.Fatalf("sanitize=%v unmarshal: %v (out=%s)", sanitize, err, out)
}
msgs := obj["messages"].([]any)
content := msgs[0].(map[string]any)["content"].([]any)
var part map[string]any
for _, rawPart := range content {
candidate, ok := rawPart.(map[string]any)
if ok && candidate["type"] == "image_url" {
part = candidate
break
}
}
if part == nil {
t.Fatal("image_url part not found")
}
if tc.want == nil {
if _, exists := part["image_url"]; exists {
t.Fatalf("sanitize=%v: missing image_url should stay missing, got %#v", sanitize, part)
}
continue
}
if got := part["image_url"]; !reflect.DeepEqual(got, tc.want) {
t.Errorf("sanitize=%v: image_url=%#v want %#v", sanitize, got, tc.want)
}
}
})
}
}
// TestNormalizeToolPatterns 工具 schema pattern 的 `\_` 转义归一(11129 实案:
// exa agent_run 的 `^agent\_run\_` 被 deepseek 系确定性拒收,归一后上游 200)。
// 覆盖:function.parameters 与裸 parameters 两形态、嵌套 schema、patternProperties
// 键、消息正文不碰、无 tools no-op、sanitize 开关两态一致。
func TestNormalizeToolPatterns(t *testing.T) {
lookupPattern := func(t *testing.T, body string, path ...any) string {
t.Helper()
var obj map[string]any
if err := json.Unmarshal([]byte(body), &obj); err != nil {
t.Fatalf("out not json: %v", err)
}
cur := any(obj)
for _, p := range path {
switch k := p.(type) {
case string:
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: want object, got %T", path, cur)
}
cur = m[k]
case int:
l, ok := cur.([]any)
if !ok || k >= len(l) {
t.Fatalf("path %v: bad array at %T", path, cur)
}
cur = l[k]
}
}
s, _ := cur.(string)
return s
}
t.Run("exa agent_run pattern normalized", func(t *testing.T) {
bs := string(byte(92)) // 反斜杠,测试体经工具链多层转义易被吞,运行时拼装保真
body := `{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function","function":{"name":"agent_run","parameters":{"type":"object","properties":{"runId":{"type":"string","pattern":"^agent` + bs + bs + `_run` + bs + bs + `_"}` + `,"query":{"type":"string"}},"required":["query"]}}}]}` //nolint:lll // 实案 body 原样
out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil))
if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "runId", "pattern"); got != `^agent_run_` {
t.Fatalf("pattern = %q, want %q", got, `^agent_run_`)
}
// 同 schema 的兄弟字段不受影响
if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "query", "type"); got != "string" {
t.Fatalf("sibling field disturbed: %q", got)
}
})
t.Run("nested anyOf items pattern normalized", func(t *testing.T) {
bs := string(byte(92))
body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"anyOf":[{"properties":{"code":{"pattern":"x` + bs + bs + `_y"}}},{"type":"string"}]}}}]}` //nolint:lll
out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil))
if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "anyOf", 0, "properties", "code", "pattern"); got != "x_y" {
t.Fatalf("nested pattern = %q, want %q", got, "x_y")
}
})
t.Run("bare tool parameters form normalized", func(t *testing.T) {
bs := string(byte(92))
body := `{"messages":[],"tools":[{"name":"t","parameters":{"properties":{"id":{"pattern":"p` + bs + bs + `_q"}}}}]}`
out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil))
if got := lookupPattern(t, out, "tools", 0, "parameters", "properties", "id", "pattern"); got != "p_q" {
t.Fatalf("bare parameters pattern = %q, want %q", got, "p_q")
}
})
t.Run("patternProperties key normalized", func(t *testing.T) {
bs := string(byte(92))
body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"patternProperties":{"^a` + bs + bs + `_b` + bs + bs + `_":{"type":"string"}}}}}]}` //nolint:lll
var obj map[string]any
out := PrepareBodyOptWithEfforts([]byte(body), false, nil)
if err := json.Unmarshal(out, &obj); err != nil {
t.Fatalf("out not json: %v", err)
}
tool := obj["tools"].([]any)[0].(map[string]any)["function"].(map[string]any)["parameters"].(map[string]any)
props := tool["patternProperties"].(map[string]any)
if _, ok := props["^a_b_"]; !ok {
t.Fatalf("normalized key missing: %v", props)
}
for k := range props {
if strings.Contains(k, string(byte(92))) {
t.Fatalf("patternProperties key not normalized: %q", k)
}
}
})
t.Run("message content backslash untouched", func(t *testing.T) {
bs := string(byte(92))
raw := `{"messages":[{"role":"user","content":"path C:` + bs + bs + `_dir and regex a` + bs + bs + `_b"}],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"q` + bs + bs + `_r"}}}}}]}` //nolint:lll
out := string(PrepareBodyOptWithEfforts([]byte(raw), false, nil))
var obj map[string]any
if err := json.Unmarshal([]byte(out), &obj); err != nil {
t.Fatalf("out not json: %v", err)
}
msg := obj["messages"].([]any)[0].(map[string]any)
if got := msg["content"]; got != `path C:\_dir and regex a\_b` {
t.Fatalf("message content mutated: %q", got)
}
})
t.Run("clean pattern untouched", func(t *testing.T) {
bs := string(byte(92))
body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"^[a-z]+` + bs + bs + `d_$"}}}}}]}` //nolint:lll
out := string(PrepareBodyOptWithEfforts([]byte(body), false, nil))
if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != `^[a-z]+\d_$` {
t.Fatalf("clean pattern mutated: %q", got)
}
})
t.Run("no tools is no-op", func(t *testing.T) {
bs := string(byte(92))
body := `{"model":"m","messages":[{"role":"user","content":"a` + bs + bs + `_b"}]}`
var inObj, outObj map[string]any
if err := json.Unmarshal([]byte(body), &inObj); err != nil {
t.Fatal(err)
}
out := PrepareBodyOptWithEfforts([]byte(body), false, nil)
if err := json.Unmarshal(out, &outObj); err != nil {
t.Fatalf("out not json: %v", err)
}
inMsg, _ := json.Marshal(inObj["messages"])
outMsg, _ := json.Marshal(outObj["messages"])
if string(inMsg) != string(outMsg) {
t.Fatalf("messages changed without tools: %s -> %s", inMsg, outMsg)
}
if _, has := outObj["tools"]; has {
t.Fatal("tools appeared out of nowhere")
}
})
t.Run("sanitize on and off behave the same", func(t *testing.T) {
bs := string(byte(92))
body := `{"messages":[],"tools":[{"type":"function","function":{"name":"t","parameters":{"properties":{"p":{"pattern":"a` + bs + bs + `_b"}}}}}]}` //nolint:lll
for _, sanitize := range []bool{false, true} {
out := string(PrepareBodyOptWithEfforts([]byte(body), sanitize, nil))
if got := lookupPattern(t, out, "tools", 0, "function", "parameters", "properties", "p", "pattern"); got != "a_b" {
t.Fatalf("sanitize=%v: pattern = %q, want %q", sanitize, got, "a_b")
}
}
})
}
// TestClampGPTMinMaxTokens GPT 系上游要求 max_tokens ≥ 16(实测 gpt-6-sol/gpt-6-luna/
// gpt-5.6-sol:15 → 400 code=11133 model_param_invalid,16 → 200)。Claude Code 切
// 模型时的探针请求 max_tokens 极小,全号轮转同样被拒 → 客户端 503。非 GPT 模型不动。
func TestClampGPTMinMaxTokens(t *testing.T) {
cases := []struct {
name string
body string
want any // nil 表示字段不存在
}{
{"gpt below floor", `{"model":"gpt-6-sol","max_tokens":1,"messages":[]}`, float64(16)},
{"gpt global prefix", `{"model":"global:gpt-5.6-sol","max_tokens":15,"messages":[]}`, float64(16)},
{"gpt at floor", `{"model":"gpt-6-luna","max_tokens":16,"messages":[]}`, float64(16)},
{"gpt above floor", `{"model":"gpt-6-sol","max_tokens":32000,"messages":[]}`, float64(32000)},
{"gpt alias translated then clamped", `{"model":"gpt-6-sol","max_completion_tokens":1,"messages":[]}`, float64(16)},
{"gpt absent untouched", `{"model":"gpt-6-sol","messages":[]}`, nil},
{"non-gpt untouched", `{"model":"hy4-preview-f","max_tokens":1,"messages":[]}`, float64(1)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
obj, err := decodeBody(PrepareBodyOptWithEffertsPreserve(t, c.body))
if err != nil {
t.Fatalf("unmarshal: %v", err)
}
got, has := obj["max_tokens"]
if c.want == nil {
if has {
t.Fatalf("max_tokens = %v, want absent", got)
}
return
}
if got != c.want {
t.Fatalf("max_tokens = %v, want %v", got, c.want)
}
})
}
}