- 语义:只把 .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 生效。
281 lines
11 KiB
Go
281 lines
11 KiB
Go
package runtimeconfig
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件钉住「重置为 env 默认」这个**显式**动作的语义(切片 R1,实现见 reset.go):
|
||
// 只改 `.env` 声明过的键、env 未声明的键原样保留、组集合与顺序不增减、面板值已与
|
||
// env 一致时不写盘(幂等),且失败一个字节都不落盘 —— 与 materialize 的补齐方向相反、
|
||
// 键表同源。
|
||
|
||
// resetPanelFile 写一份含三组的面板配置:openai 组(面板值全都与 env 不同)、
|
||
// anthropic 组(env 侧另有一块 anthropic 声明)、以及 env 里完全没有对应声明的
|
||
// 第三组(它的面板值只可能被保留)。返回文件路径。
|
||
func resetPanelFile(t *testing.T) string {
|
||
t.Helper()
|
||
return writeModelsFile(t, `[
|
||
{"id":"panel-openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel-key","model":"panel-model",
|
||
"fallback":"panel-fallback","multimodal":true,"maxTokens":4096,"contextWindow":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"},
|
||
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"}
|
||
]`)
|
||
}
|
||
|
||
// TestResetFromEnvOnlyTouchesEnvDeclaredKeys 是核心语义:`.env` 声明过的键被改回
|
||
// env 值,env 未声明的键(fallback / multimodal / maxTokens / reasoning /
|
||
// personaModels)**原样保留**;`provider` 虽在 env 默认层里被声明,但面板值已与
|
||
// env 一致,因此不算「被改回」。
|
||
func TestResetFromEnvOnlyTouchesEnvDeclaredKeys(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
env.ContextWindow = 131072
|
||
path := resetPanelFile(t)
|
||
|
||
res, err := ResetFromEnv(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("ResetFromEnv: %v", err)
|
||
}
|
||
if !res.Written {
|
||
t.Fatal("Written = false, want true(有键被改回就该落盘)")
|
||
}
|
||
// 三组都在:openai 组被改回,anthropic 组被改回,extra 组也一样(同属 openai 族)。
|
||
got := map[string][]string{}
|
||
for _, g := range res.Reset {
|
||
got[g.GroupID] = g.Keys
|
||
}
|
||
wantOpenAI := []string{"baseUrl", "apiKey", "model", "contextWindow"}
|
||
if !equalStrings(got["panel-openai"], wantOpenAI) {
|
||
t.Fatalf("panel-openai 改回的键 = %v, want %v(provider 已一致,不算改回)", got["panel-openai"], wantOpenAI)
|
||
}
|
||
wantAnthropic := []string{"baseUrl", "apiKey", "model", "contextWindow"}
|
||
if !equalStrings(got["panel-anthropic"], wantAnthropic) {
|
||
t.Fatalf("panel-anthropic 改回的键 = %v, want %v(anthropic 族按组自己的 provider 选族;LLM_CONTEXT_WINDOW 对两族都播种)",
|
||
got["panel-anthropic"], wantAnthropic)
|
||
}
|
||
if len(res.Skipped) != 0 {
|
||
t.Fatalf("Skipped = %+v, want 空(每一组都有键被改回)", res.Skipped)
|
||
}
|
||
|
||
// 磁盘上的真值:env 值覆盖,未声明的键原样保留。
|
||
groups, warn := LoadModels(path, env)
|
||
if warn != nil {
|
||
t.Fatalf("LoadModels: warn = %v", warn)
|
||
}
|
||
g := mustFindGroup(t, groups, "panel-openai")
|
||
if g.BaseURL != env.BaseURL || g.APIKey != env.APIKey || g.Model != env.Model || g.ContextWindow != env.ContextWindow {
|
||
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("panel-openai = %+v, want env 未声明的键原样保留", g)
|
||
}
|
||
ant := mustFindGroup(t, groups, "panel-anthropic")
|
||
if ant.BaseURL != env.AnthropicBaseURL || ant.APIKey != env.AnthropicAPIKey || ant.Model != env.AnthropicModel {
|
||
t.Fatalf("panel-anthropic = %+v, want anthropic 族的 env 值", ant)
|
||
}
|
||
// 组集合与顺序不变(panel-extra 是面板新增的组,env 里没有它的 id)。
|
||
if len(groups) != 3 || groups[0].ID != "panel-openai" || groups[1].ID != "panel-anthropic" || groups[2].ID != "panel-extra" {
|
||
t.Fatalf("组集合/顺序 = %+v, want 原样的三组", groups)
|
||
}
|
||
}
|
||
|
||
// mustFindGroup 按 id 取组,取不到直接失败。
|
||
func mustFindGroup(t *testing.T, groups []Group, id string) Group {
|
||
t.Helper()
|
||
g, ok := Find(groups, id)
|
||
if !ok {
|
||
t.Fatalf("组 %q 不在磁盘上: %+v", id, groups)
|
||
}
|
||
return g
|
||
}
|
||
|
||
// TestResetFromEnvKeepsGroupsEnvWantsBack 钉住「组集合不因 env 而增减」:env 里
|
||
// 有 anthropic 块,但面板把 anthropic 组删了 —— 重置**不复活**它;面板新增的组
|
||
// 也**不被删除**(组数与顺序都不动)。
|
||
func TestResetFromEnvKeepsGroupsEnvWantsBack(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
path := writeModelsFile(t, `[
|
||
{"id":"panel-extra","provider":"openai","baseUrl":"https://panel-extra.example/v1","apiKey":"sk-panel-extra","model":"panel-extra-model"}
|
||
]`)
|
||
before := readRaw(t, path)
|
||
|
||
res, err := ResetFromEnv(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("ResetFromEnv: %v", err)
|
||
}
|
||
if !res.Written {
|
||
t.Fatal("Written = false, want true(openai 组确实被改回)")
|
||
}
|
||
groups, warn := LoadModels(path, env)
|
||
if warn != nil {
|
||
t.Fatalf("LoadModels: warn = %v", warn)
|
||
}
|
||
if len(groups) != 1 || groups[0].ID != "panel-extra" {
|
||
t.Fatalf("组集合 = %+v, want 只有面板那一组(envelope 不复活被删的组)", groups)
|
||
}
|
||
if _, ok := Find(groups, ProviderAnthropic); ok {
|
||
t.Fatal("env 声明的 anthropic 组被复活了, want 不新增组")
|
||
}
|
||
if readRaw(t, path) == before {
|
||
t.Fatal("openai 组的键应被改回(文件该变)")
|
||
}
|
||
}
|
||
|
||
// TestResetFromEnvIdempotentNoWrite 钉住幂等:第二次调用时面板值已与 env 一致,
|
||
// 因此**一个字节都不动**,并且报出「已一致」的跳过原因。
|
||
func TestResetFromEnvIdempotentNoWrite(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
path := resetPanelFile(t)
|
||
if _, err := ResetFromEnv(path, env, discardLog()); err != nil {
|
||
t.Fatalf("第一次 ResetFromEnv: %v", err)
|
||
}
|
||
before := readRaw(t, path)
|
||
|
||
res, err := ResetFromEnv(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("第二次 ResetFromEnv: %v", err)
|
||
}
|
||
if res.Written {
|
||
t.Fatal("Written = true, want false(无键可改时不写盘)")
|
||
}
|
||
if len(res.Reset) != 0 {
|
||
t.Fatalf("Reset = %+v, want 空", res.Reset)
|
||
}
|
||
if len(res.Skipped) != 3 {
|
||
t.Fatalf("Skipped = %+v, want 三组都报「已一致」", res.Skipped)
|
||
}
|
||
for _, s := range res.Skipped {
|
||
if s.Reason != SkipReasonAlreadyDefault {
|
||
t.Fatalf("跳过原因 = %q, want %q", s.Reason, SkipReasonAlreadyDefault)
|
||
}
|
||
}
|
||
if readRaw(t, path) != before {
|
||
t.Fatal("无键可改时文件被改动了, want 逐字节不变")
|
||
}
|
||
}
|
||
|
||
// TestResetFromEnvUnreadablePanelFails 钉住失败路径:面板文件不存在/不可解析时
|
||
// 明确报错(ErrPanelUnreadable),绝不假装成功、也绝不创建文件。
|
||
func TestResetFromEnvUnreadablePanelFails(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
|
||
t.Run("文件不存在", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "models.json")
|
||
if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) {
|
||
t.Fatalf("err = %v, want ErrPanelUnreadable", err)
|
||
}
|
||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||
t.Fatal("重置失败却创建了文件, want 什么都不做")
|
||
}
|
||
})
|
||
|
||
t.Run("内容不可解析", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `{"not":"an array"}`)
|
||
before := readRaw(t, path)
|
||
if _, err := ResetFromEnv(path, env, discardLog()); !errors.Is(err, ErrPanelUnreadable) {
|
||
t.Fatalf("err = %v, want ErrPanelUnreadable", err)
|
||
}
|
||
if readRaw(t, path) != before {
|
||
t.Fatal("解析失败的文件被改写了, want 一个字节都不动")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestResetFromEnvEnvDeclaresNothing 钉住「env 什么都没声明」这一侧:组的
|
||
// baseUrl/model 等一个都不动,且**每一组**都报「env 未声明该组的默认键」——这一条
|
||
// 之所以能成立,靠的是把 `provider`(同义反复,恒等于组里已有的值)排除在
|
||
// 「env 声明了什么」的判定之外,否则面板上会显示成误导性的「面板值已一致」。
|
||
func TestResetFromEnvEnvDeclaresNothing(t *testing.T) {
|
||
t.Parallel()
|
||
env := config.LLMConfig{Provider: ProviderOpenAI}
|
||
path := resetPanelFile(t)
|
||
before := readRaw(t, path)
|
||
|
||
res, err := ResetFromEnv(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("ResetFromEnv: %v", err)
|
||
}
|
||
if res.Written || len(res.Reset) != 0 {
|
||
t.Fatalf("res = %+v, want 无改动(env 未声明任何键)", res)
|
||
}
|
||
if len(res.Skipped) != 3 {
|
||
t.Fatalf("Skipped = %+v, want 三组都被跳过", res.Skipped)
|
||
}
|
||
for _, s := range res.Skipped {
|
||
if s.Reason != SkipReasonNoDefaults {
|
||
t.Fatalf("组 %s 的跳过原因 = %q, want %q", s.GroupID, s.Reason, SkipReasonNoDefaults)
|
||
}
|
||
}
|
||
if readRaw(t, path) != before {
|
||
t.Fatal("env 未声明任何键时文件被改动了, want 逐字节不变")
|
||
}
|
||
}
|
||
|
||
// TestResetFromEnvSkipsFamilyWithoutDeclaration 钉住「按组自己的 provider 选族」的
|
||
// 另一面:`.env` 只配了扁平 openai 块时,anthropic 组没有任何可改回的键,且原因必须
|
||
// 是「`.env` 未声明」而不是「已一致」。
|
||
func TestResetFromEnvSkipsFamilyWithoutDeclaration(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv() // 含 anthropic 块
|
||
env.AnthropicBaseURL, env.AnthropicAPIKey, env.AnthropicModel, env.AnthropicFallback = "", "", "", ""
|
||
path := resetPanelFile(t)
|
||
|
||
res, err := ResetFromEnv(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("ResetFromEnv: %v", err)
|
||
}
|
||
var anthropic *ResetSkip
|
||
for i := range res.Skipped {
|
||
if res.Skipped[i].GroupID == "panel-anthropic" {
|
||
anthropic = &res.Skipped[i]
|
||
}
|
||
}
|
||
if anthropic == nil {
|
||
t.Fatalf("anthropic 组不在 skipped 里: %+v", res.Skipped)
|
||
}
|
||
if anthropic.Reason != SkipReasonNoDefaults {
|
||
t.Fatalf("anthropic 组跳过原因 = %q, want %q", anthropic.Reason, SkipReasonNoDefaults)
|
||
}
|
||
}
|
||
|
||
// TestResetFromEnvNeverLogsCredentialValues 钉住凭据纪律:日志里只有组 id 与
|
||
// 键名,env 与面板的 apiKey 值都不出现。
|
||
func TestResetFromEnvNeverLogsCredentialValues(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||
env := materializeEnv()
|
||
path := resetPanelFile(t)
|
||
|
||
if _, err := ResetFromEnv(path, env, log); err != nil {
|
||
t.Fatalf("ResetFromEnv: %v", err)
|
||
}
|
||
out := buf.String()
|
||
for _, secret := range []string{env.APIKey, env.AnthropicAPIKey, "sk-panel-key", "sk-panel-ant"} {
|
||
if strings.Contains(out, secret) {
|
||
t.Fatalf("日志里出现了凭据 %q: %s", secret, out)
|
||
}
|
||
}
|
||
if !strings.Contains(out, `"source":"config/runtimeconfig"`) {
|
||
t.Fatalf("日志缺来源标注: %s", out)
|
||
}
|
||
if !strings.Contains(out, "panel-openai.baseUrl") {
|
||
t.Fatalf("日志未报告被改回的键(可审计): %s", out)
|
||
}
|
||
}
|