package gateway import ( "encoding/json" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 组级 `contextWindow`(切片 T1 落库的声明式模型窗口)在管理面板读写面上的锁定: // 读侧如实回填(否则面板一保存就把窗口抹掉)、写侧真的落进 models.json、 // 0/省略 = 未声明、负数 400 且不落盘。线上的形状与语义逐处对齐 `maxTokens`。 // TestAdminAddLlmProviderPersistsContextWindow 钉住写侧落库且两侧都回填该键。 func TestAdminAddLlmProviderPersistsContextWindow(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) resp, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "win-group", "provider": "openai", "baseUrl": "https://window.example/v1", "apiKey": "sk-win", "model": "long-context-model", "contextWindow": 1048576, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } var res llmAddResult if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode add: %v", err) } if res.Provider.ContextWindow != 1048576 { t.Fatalf("保存响应回显的窗口 = %d, want 1048576", res.Provider.ContextWindow) } // models.json 里真的落到该组(不只是内存栈里对)。 groups, raw := storedGroups(t, modelsPath) if g := findStored(t, groups, "win-group"); g.ContextWindow != 1048576 { t.Fatalf("models.json 里的窗口 = %d, want 1048576", g.ContextWindow) } if !strings.Contains(raw, `"contextWindow"`) { t.Fatalf("models.json 缺 contextWindow 键:%s", raw) } // GET /admin/llm 同样回填(面板靠它播种表单)。 gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) if gResp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody) } var cfg AdminLlmConfig if err := json.Unmarshal(decodeBody(t, gBody)["data"], &cfg); err != nil { t.Fatalf("decode get: %v", err) } if got, ok := findDTO(cfg.Providers, "win-group"); !ok || got.ContextWindow != 1048576 { t.Fatalf("GET providers = %+v, want win-group 带 contextWindow 1048576", cfg.Providers) } } // TestAdminLlmProviderOmitsUndeclaredContextWindow 钉住 0 = 未声明的线上形状: // DTO 与 models.json 都**不出现**该键(omitempty,与 maxTokens 同形)。面板因此 // 把「未声明」当成一种明确状态回填,而不是读到一个 0 再去猜它是什么意思。 func TestAdminLlmProviderOmitsUndeclaredContextWindow(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) resp, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "nowin", "provider": "openai", "baseUrl": "https://nowin.example/v1", "apiKey": "sk-nowin", "model": "m1", }) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } if _, raw := storedGroups(t, modelsPath); strings.Contains(raw, "contextWindow") { t.Fatalf("未声明的窗口不该落进 models.json:%s", raw) } gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) if gResp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody) } if strings.Contains(string(gBody), "contextWindow") { t.Fatalf("未声明的窗口在读 DTO 里应是「键省略」而不是 0:%s", gBody) } } // TestAdminAddLlmProviderKeepsContextWindowOnPartialEdit 钉住 P0 级不变量:面板 // 只提交用户碰过的字段,**省略该键绝不清掉已存窗口**。 func TestAdminAddLlmProviderKeepsContextWindowOnPartialEdit(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) seed, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "win-group", "provider": "openai", "baseUrl": "https://window.example/v1", "apiKey": "sk-win", "model": "m1", "contextWindow": 32768, }) if seed.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) } // 只改 model:请求里没有 contextWindow,必须原样保留。 resp, body2 := postLlmProvider(t, ts, tok, map[string]any{"id": "win-group", "model": "m2"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("edit status = %d, want 201 (body=%s)", resp.StatusCode, body2) } groups, _ := storedGroups(t, modelsPath) if g := findStored(t, groups, "win-group"); g.Model != "m2" || g.ContextWindow != 32768 { t.Fatalf("stored group = %+v, want model m2 且窗口 32768 保留", g) } // 显式 0 与省略同义(都是「未声明」,与 Group.Validate 同口径),因此它会把 // 已存窗口清回未声明——面板只在用户真的填了 0 时才发送它。 resp, body3 := postLlmProvider(t, ts, tok, map[string]any{"id": "win-group", "contextWindow": 0}) if resp.StatusCode != http.StatusCreated { t.Fatalf("clear status = %d, want 201 (body=%s)", resp.StatusCode, body3) } groups, raw := storedGroups(t, modelsPath) if g := findStored(t, groups, "win-group"); g.ContextWindow != 0 { t.Fatalf("显式 0 后的窗口 = %d, want 0(未声明)", g.ContextWindow) } if strings.Contains(raw, "contextWindow") { t.Fatalf("未声明的窗口不该留在 models.json:%s", raw) } } // TestAdminAddLlmProviderRejectsNegativeContextWindow 钉住负数窗口 400、错误信封 // 点名字段、且**不落盘**(新组不建、已存组的窗口不动)。 func TestAdminAddLlmProviderRejectsNegativeContextWindow(t *testing.T) { t.Parallel() modelsPath, ts, tok := llmTestEnv(t) seed, body := postLlmProvider(t, ts, tok, map[string]any{ "id": "win-group", "provider": "openai", "baseUrl": "https://window.example/v1", "apiKey": "sk-win", "model": "m1", "contextWindow": 32768, }) if seed.StatusCode != http.StatusCreated { t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) } cases := []struct { name string body map[string]any }{ {"已存组改成负数", map[string]any{"id": "win-group", "contextWindow": -1}}, {"新组直接给负数", map[string]any{ "id": "neg-group", "provider": "openai", "baseUrl": "https://neg.example/v1", "model": "m1", "contextWindow": -4096, }}, } for _, tc := range cases { resp, respBody := postLlmProvider(t, ts, tok, tc.body) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("%s: status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, respBody) } if errObj := decodeBody(t, respBody)["error"]; len(errObj) == 0 { t.Fatalf("%s: 400 没有 error 信封(body=%s)", tc.name, respBody) } if !strings.Contains(string(respBody), "contextWindow") { t.Fatalf("%s: 错误消息没点名字段(body=%s)", tc.name, respBody) } } groups, raw := storedGroups(t, modelsPath) if g := findStored(t, groups, "win-group"); g.ContextWindow != 32768 { t.Fatalf("被拒的请求改了已存窗口:%+v", g) } if _, ok := runtimeconfig.Find(groups, "neg-group"); ok { t.Fatalf("被拒的组被落盘了:%+v", groups) } if strings.Contains(raw, "-1") || strings.Contains(raw, "-4096") { t.Fatalf("被拒的值漏进了 models.json:%s", raw) } }