Files
Cyrene_v2/internal/services/gateway/admin_llm_context_window_test.go
AskaEth 47e56ca13c 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)。
2026-10-04 18:11:43 +08:00

173 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}