feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
This commit is contained in:
AskaEth committed 2026-10-04 21:33:11 +08:00
1 parent 367abf2c8e
commit c1fa1e9b9a
19 files changed
+1434 -11

No files matched your search

@@ -0,0 +1,326 @@
package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住「重置为 env 默认」动作(切片 R1,实现见 admin_write_llm_reset.go)的
// **语义**侧:只覆盖 `.env` 声明过的键、env 未声明的键原样保留、组集合不增减、幂等。
// 护栏(confirm / RBAC / 审计)与失败路径在 admin_write_llm_reset_guard_test.go。
//
// 用例全部走**临时目录 + 临时 `.env` 文件 + 临时 models.json**:仓库根的 `.env` 与
// `data/` 一个字节都不碰。
const (
// resetEnvKey / resetPanelKey 是两个必须**永不回显**的凭据明文。
resetEnvKey = "sk-env-reset-plaintext"
resetPanelKey = "sk-panel-reset-plaintext"
)
// resetPanelJSON 是面板(models.json)的初始状态:openai 组与 anthropic 组的面板值
// 全都与临时 `.env` 的声明不同,且带几把 env 里**没有出处**的键(fallback /
// multimodal / maxTokens / reasoning / personaModels)——它们必须原样活下来。
const resetPanelJSON = `[
{"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-model",
"fallback":"panel-fallback","multimodal":true,"maxTokens":4096,
"reasoning":{"kind":"vllm"},"personaModels":{"cyrene":"panel-model"}},
{"id":"panel-anthropic","provider":"anthropic","baseUrl":"https://panel-anthropic.example","apiKey":"sk-panel-ant",
"model":"panel-claude","fallback":"panel-ant-fallback"}
]`
// llmResetFixture 是一次重置用例的运行环境。
type llmResetFixture struct {
modelsPath string
envPath string
runtime *model.RuntimeLLM
ts *httptest.Server
jwt *auth.JWTService
before string
}
// newLlmResetFixture 写面板文件 + 临时 `.env`,接上可热重载的 LLM 栈并起测试服务。
// envPath 留空 = 用临时 `.env`;「env 源不可读」的用例自己传一个目录路径进来。
func newLlmResetFixture(t *testing.T, envPath, panel string, deps Deps) *llmResetFixture {
t.Helper()
modelsPath, before := writeResetPanelFile(t, panel)
if envPath == "" {
envPath = writeResetEnvFile(t)
}
runtime := newLlmRuntimeForGateway(t, modelsPath, llmEnvForTest())
deps.LLM = runtime
deps.EnvPath = envPath
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
return &llmResetFixture{modelsPath: modelsPath, envPath: envPath, runtime: runtime, ts: ts, jwt: jwt, before: before}
}
// post 发一次重置请求(token 为空 = 匿名)。
func (f *llmResetFixture) post(t *testing.T, tok string, body map[string]any) (*http.Response, []byte) {
t.Helper()
return doReq(t, http.MethodPost, f.ts.URL+"/api/v1/admin/llm/reset-from-env", body, tok)
}
// ownerToken 签发 owner token(每个用例自带一个,避免共享状态)。
func (f *llmResetFixture) ownerToken(t *testing.T) string {
t.Helper()
return signAny(t, f.jwt, "acct", auth.RoleOwner)
}
// writeResetEnvFile 写一份临时 `.env` 默认层并返回其路径(`LLM_PROVIDER` 缺省按
// 标签默认 openai;两族都声明,好让用例分辨「按组自己的 provider 选族」)。
func writeResetEnvFile(t *testing.T) string {
t.Helper()
content := strings.Join([]string{
"# 重置用例的临时 env 默认层(绝不写到仓库根 .env)",
"LLM_BASE_URL=https://env.example/v1",
"LLM_API_KEY=" + resetEnvKey,
"LLM_MODEL=env-model",
"LLM_CONTEXT_WINDOW=131072",
"ANTHROPIC_BASE_URL=https://env-anthropic.example",
"ANTHROPIC_API_KEY=sk-env-anthropic-plaintext",
"ANTHROPIC_MODEL=env-claude",
"",
}, "\n")
path := filepath.Join(t.TempDir(), "reset.env")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
return path
}
// writeResetPanelFile 写一份面板 models.json,返回路径与文件原文。
func writeResetPanelFile(t *testing.T, panel string) (string, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
if err := os.WriteFile(path, []byte(panel), 0o600); err != nil {
t.Fatalf("写面板 models.json: %v", err)
}
return path, readResetPanel(t, path)
}
// readResetPanel 读回面板文件原文(幂等/不动的断言必须比字节,不能只看解出来的组)。
func readResetPanel(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读回 %s: %v", path, err)
}
return string(raw)
}
// llmResetResponse 是 POST /api/v1/admin/llm/reset-from-env 的响应体:字段名逐字
// 对齐网关 DTO(llmResetGroup / llmResetSkip)。
type llmResetResponse struct {
Reset []struct {
Group string `json:"group"`
Keys []string `json:"keys"`
} `json:"reset"`
Skipped []struct {
Group string `json:"group"`
Reason string `json:"reason"`
} `json:"skipped"`
Path string `json:"path"`
}
// decodeResetResponse 解响应体的 data 段。
func decodeResetResponse(t *testing.T, body []byte) llmResetResponse {
t.Helper()
var res llmResetResponse
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode reset response: %v (body=%s)", err, body)
}
return res
}
// resetKeys 取某个组被改回的键名。
func resetKeys(t *testing.T, res llmResetResponse, group string) []string {
t.Helper()
for _, g := range res.Reset {
if g.Group == group {
return g.Keys
}
}
t.Fatalf("组 %q 不在 reset 列表里: %+v", group, res.Reset)
return nil
}
// diskGroups 从磁盘读回组(真实落盘断言)。
func diskGroups(t *testing.T, path string) []runtimeconfig.Group {
t.Helper()
groups, warn := runtimeconfig.LoadModels(path, config.LLMConfig{})
if warn != nil {
t.Fatalf("LoadModels: warn = %v", warn)
}
return groups
}
func TestAdminResetLlmFromEnvOnlyResetsEnvDeclaredKeys(t *testing.T) {
t.Parallel()
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{})
tok := f.ownerToken(t)
resp, body := f.post(t, tok, map[string]any{"confirm": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
res := decodeResetResponse(t, body)
if res.Path != f.modelsPath {
t.Fatalf("path = %q, want %q", res.Path, f.modelsPath)
}
// 只回**真的被改回**的键(provider 在 env 默认层里也有声明,但面板值已一致 ⇒ 不算改回)。
wantKeys := []string{"baseUrl", "apiKey", "model", "contextWindow"}
if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, wantKeys) {
t.Fatalf("panel-openai 改回的键 = %v, want %v", got, wantKeys)
}
if got := resetKeys(t, res, "panel-anthropic"); !equalStringSlices(got, wantKeys) {
t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族)", got, wantKeys)
}
if len(res.Skipped) != 0 {
t.Fatalf("skipped = %+v, want 空(两组都有键被改回)", res.Skipped)
}
// 磁盘真值:env 值覆盖,env 未声明的键原样保留。
groups := diskGroups(t, f.modelsPath)
if len(groups) != 2 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" {
t.Fatalf("组集合/顺序 = %+v, want 原样的两组", groups)
}
g := groups[0]
if g.BaseURL != "https://env.example/v1" || g.APIKey != resetEnvKey || g.Model != "env-model" || g.ContextWindow != 131072 {
t.Fatalf("panel-openai = %+v, want env 的 baseUrl/apiKey/model/contextWindow", g)
}
if g.Fallback != "panel-fallback" || !g.Multimodal || g.MaxTokens != 4096 ||
g.Reasoning.Kind != "vllm" || g.PersonaModels["cyrene"] != "panel-model" {
t.Fatalf("env 未声明的键被动了: %+v", g)
}
ant := groups[1]
if ant.BaseURL != "https://env-anthropic.example" || ant.APIKey != "sk-env-anthropic-plaintext" ||
ant.Model != "env-claude" || ant.Fallback != "panel-ant-fallback" {
t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值 + 保留 fallback", ant)
}
// 运行中的栈真的换了配置(热重载,无需重启):生效组(下标 0)已退回 env 基线。
if f.runtime.Group().ID != "panel-openai" || f.runtime.Model() != "env-model" {
t.Fatalf("runtime group=%q model=%q, want 热重载到 env 基线", f.runtime.Group().ID, f.runtime.Model())
}
// 凭据纪律:两个 key 的明文都不在响应体里(只回键名)。
for _, secret := range []string{resetEnvKey, resetPanelKey, "sk-env-anthropic-plaintext", "sk-panel-ant"} {
if strings.Contains(string(body), secret) {
t.Fatalf("响应体回显了凭据 %q: %s", secret, body)
}
}
}
// equalStringSlices 比对两份字符串切片(顺序敏感:键顺序即 groupFillSpecs 顺序)。
func equalStringSlices(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
// TestAdminResetLlmFromEnvKeepsGroupSet 钉住「组集合不增减」:面板删掉的 anthropic 组
// 不因 `.env` 仍声明它而复活,面板新增的组也不被删除(顺序一并保持)。
func TestAdminResetLlmFromEnvKeepsGroupSet(t *testing.T) {
t.Parallel()
panel := `[
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"` + resetPanelKey + `","model":"panel-extra-model"}
]`
f := newLlmResetFixture(t, "", panel, Deps{})
tok := f.ownerToken(t)
resp, body := f.post(t, tok, map[string]any{"confirm": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := resetKeys(t, decodeResetResponse(t, body), "panel-extra"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model", "contextWindow"}) {
t.Fatalf("panel-extra 改回的键 = %v, want [baseUrl apiKey model contextWindow]", got)
}
groups := diskGroups(t, f.modelsPath)
if len(groups) != 1 || groups[0].ID != "panel-extra" {
t.Fatalf("组集合 = %+v, want 只有面板那一组(不复活被删的 anthropic 组)", groups)
}
if _, ok := runtimeconfig.Find(groups, runtimeconfig.ProviderAnthropic); ok {
t.Fatal("`.env` 声明的 anthropic 组被复活了, want 不新增组")
}
}
// TestAdminResetLlmFromEnvIdempotent 钉住幂等:第二次调用没有任何键要改,于是不写盘、
// 两组都报「已与 env 一致」。
func TestAdminResetLlmFromEnvIdempotent(t *testing.T) {
t.Parallel()
f := newLlmResetFixture(t, "", resetPanelJSON, Deps{})
tok := f.ownerToken(t)
if resp, body := f.post(t, tok, map[string]any{"confirm": true}); resp.StatusCode != http.StatusOK {
t.Fatalf("第一次 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
afterFirst := readResetPanel(t, f.modelsPath)
resp, body := f.post(t, tok, map[string]any{"confirm": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("第二次 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
res := decodeResetResponse(t, body)
if len(res.Reset) != 0 {
t.Fatalf("reset = %+v, want 空(面板值已与 env 一致)", res.Reset)
}
if len(res.Skipped) != 2 {
t.Fatalf("skipped = %+v, want 两组都报「已一致」", res.Skipped)
}
for _, s := range res.Skipped {
if s.Reason != runtimeconfig.SkipReasonAlreadyDefault {
t.Fatalf("跳过原因 = %q, want %q", s.Reason, runtimeconfig.SkipReasonAlreadyDefault)
}
}
if readResetPanel(t, f.modelsPath) != afterFirst {
t.Fatal("无键可改时文件被改动了, want 逐字节不变")
}
}
// TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare 钉住面板上那条跳过原因的诚实性:
// `.env` 只配了扁平 openai 块时,anthropic 族没有可改回的键,跳过原因必须是
// 「`.env` 未声明该组的默认键」而不是误导性的「面板值已一致」。
func TestAdminResetLlmFromEnvSkipsFamilyEnvDidNotDeclare(t *testing.T) {
t.Parallel()
envPath := filepath.Join(t.TempDir(), "openai-only.env")
content := "LLM_BASE_URL=https://env.example/v1\nLLM_API_KEY=" + resetEnvKey + "\nLLM_MODEL=env-model\n"
if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil {
t.Fatalf("写临时 .env: %v", err)
}
f := newLlmResetFixture(t, envPath, resetPanelJSON, Deps{})
tok := f.ownerToken(t)
resp, body := f.post(t, tok, map[string]any{"confirm": true})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
res := decodeResetResponse(t, body)
if got := resetKeys(t, res, "panel-openai"); !equalStringSlices(got, []string{"baseUrl", "apiKey", "model"}) {
t.Fatalf("panel-openai 改回的键 = %v, want [baseUrl apiKey model](扁平块声明的就是这三个)", got)
}
reason := ""
for _, s := range res.Skipped {
if s.Group == "panel-anthropic" {
reason = s.Reason
}
}
if reason != runtimeconfig.SkipReasonNoDefaults {
t.Fatalf("panel-anthropic 的跳过原因 = %q, want %q", reason, runtimeconfig.SkipReasonNoDefaults)
}
}