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:
1 parent
3a01b4e236
commit
47e56ca13c
11 files changed
+355
-68
No files matched your search
@@ -67,6 +67,11 @@ type AdminLlmProvider struct {
|
||||
// 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS,
|
||||
// 再回退到适配器默认值)。
|
||||
MaxTokens int `json:"maxTokens,omitempty"`
|
||||
// ContextWindow 是该分组声明的**模型上下文窗口**(token)。0(= 键被省略,
|
||||
// 与 MaxTokens 同形)表示未声明:运行时按回落窗口保守推导并记一条 WARN,
|
||||
// 只有声明了真实窗口才能吃到按窗口推导的上下文预算。它是 .env 侧
|
||||
// LLM_CONTEXT_WINDOW 的对偶(per-group 播种)。
|
||||
ContextWindow int `json:"contextWindow,omitempty"`
|
||||
// Reasoning 是该组的推理开关形状(裁决 03.4.2 / 08.4.1)。始终下发
|
||||
// (kind 可能为空串),使面板能把「未声明」当成一种明确状态回填。
|
||||
Reasoning *AdminLlmReasoning `json:"reasoning"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func adminLlmProviderDTO(g runtimeconfig.Group, name string, enabled bool) Admin
|
||||
Enabled: enabled,
|
||||
Fallback: g.Fallback,
|
||||
MaxTokens: g.MaxTokens,
|
||||
ContextWindow: g.ContextWindow,
|
||||
Reasoning: &AdminLlmReasoning{Kind: strings.TrimSpace(g.Reasoning.Kind)},
|
||||
PersonaModels: cloneStringMap(g.PersonaModels),
|
||||
PersonaProviders: cloneStringMap(g.PersonaProviders),
|
||||
|
||||
@@ -47,8 +47,8 @@ type llmReasoningRequest struct {
|
||||
// 「缺失」(保留已存值)与「显式零值」(覆盖):来自面板的局部
|
||||
// 编辑绝不清除它未触碰的字段。Name 是客户端侧
|
||||
// 显示标签(不持久化 —— models.json 结构含 id/provider/
|
||||
// baseUrl/apiKey/model/fallback/multimodal/maxTokens/reasoning/personaModels/
|
||||
// personaProviders)。
|
||||
// baseUrl/apiKey/model/fallback/multimodal/maxTokens/contextWindow/reasoning/
|
||||
// personaModels/personaProviders)。
|
||||
type llmProviderRequest struct {
|
||||
ID *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
@@ -61,6 +61,9 @@ type llmProviderRequest struct {
|
||||
// MaxTokens 是该分组自身的 token 预算。缺失则保留已存
|
||||
// 预算(新分组 0 = 未设置)。负值被拒绝。
|
||||
MaxTokens *int `json:"maxTokens"`
|
||||
// ContextWindow 是该分组声明的模型上下文窗口(token)。缺失则保留已存窗口
|
||||
// (新分组 0 = 未声明);显式 0 与缺失同义(都按回落窗口推导);负值被拒绝。
|
||||
ContextWindow *int `json:"contextWindow"`
|
||||
// Reasoning 缺失 = 保留已存形状;给出(含 `{}`)= 用其中的 kind 覆盖
|
||||
// (kind 缺失/空串 = 回到「未声明」,等同 auto)。
|
||||
Reasoning *llmReasoningRequest `json:"reasoning"`
|
||||
@@ -74,7 +77,7 @@ type llmProviderRequest struct {
|
||||
// adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write,
|
||||
// 并将其提升为活动分组),再热重载运行中的 LLM 栈,使
|
||||
// 新的 provider/model 无需重启即生效(design/04 §3.1)。客户端省略的
|
||||
// 字段保持其已存值,因此局部编辑绝不复位预算或
|
||||
// 字段保持其已存值,因此局部编辑绝不复位预算、窗口或
|
||||
// key。API key 被持久化进被 gitignore 的文件,但绝不返回/记日志。
|
||||
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var req llmProviderRequest
|
||||
@@ -87,6 +90,14 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
|
||||
return
|
||||
}
|
||||
// 窗口没有负数语义(与 runtimeconfig.Group.Validate 同一口径:只拒绝负数,
|
||||
// 0 与缺失都是「未声明」)。这里提前报错是为了给出面板能直接显示的字段名,
|
||||
// 规则本身不在网关另立一套。
|
||||
if req.ContextWindow != nil && *req.ContextWindow < 0 {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "contextWindow must be >= 0"))
|
||||
return
|
||||
}
|
||||
if s.llm == nil {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
|
||||
@@ -168,6 +179,9 @@ func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (run
|
||||
if req.MaxTokens != nil {
|
||||
g.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.ContextWindow != nil {
|
||||
g.ContextWindow = *req.ContextWindow
|
||||
}
|
||||
if req.Reasoning != nil {
|
||||
g.Reasoning = runtimeconfig.ReasoningOptions{Kind: reasoningKindOf(req.Reasoning)}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user