- 面板读写面与 maxTokens 逐处对齐:读 DTO 增 contextWindow(omitempty)、写请求 ContextWindow *int(缺失=保留、显式 0=未声明同义、负数 400)、回写 models.json;负数红线仍由 runtimeconfig.Group.Validate 唯一裁决,网关守卫只做「更早失败 + 面板可读字段名」。前端:类型 + 表单字段(提交前 /^\d+$/ 校验)+ 分组行角标 + Notice + 目录候选用早已解析却没人用的 contextLimit 预填。文案写明「重启后生效」(推导只在进程启动时算一次)。 - 顺手修既有红线违反:ProviderFields 87→76(抽 ApiKeyAndBudgetField)、LlmProviderForm 113→74(buildRequest 提为模块级纯函数)——本次碰过的文件里已无 >80 的函数,行为/文案逐字未变。 - DocumentRef 的 chars/truncated 注释从「抽取侧」改为「本回合注入侧」(全文长度只在 document-read 的 totalChars、extractTruncated 专指撞 8 MiB 安全上限);机械校验为空 ⇒ 代码零改动。 - 主会话独立验收:隔离 worktree Go 侧 build/vet + gateway/history 两包绿;主树 npx tsc --noEmit exit 0、npm run build 成功并 copy 到 internal/webui/dist;注释机械校验为空;变异由子代理复现(写路径不认该键 ⇒ 3 红;去掉网关负数守卫 ⇒ 400 变 500)。
173 lines
7.1 KiB
Go
173 lines
7.1 KiB
Go
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)
|
||
}
|
||
}
|