feat(admin): 面板暴露 contextWindow(可见可改)+ DocumentRef 语义注释同步

- 面板读写面与 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)。
This commit is contained in:
AskaEth committed 2026-10-04 18:11:43 +08:00
1 parent 3a01b4e236
commit 47e56ca13c
11 files changed
+355 -68

No files matched your search

@@ -0,0 +1,172 @@
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)
}
}