- 语义:只把 .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 生效。
82 lines
3.2 KiB
Go
82 lines
3.2 KiB
Go
package config
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件钉住「重置为 env 默认」动作(切片 R1)的**对照基准读取**:`.env` 默认层必须
|
||
// 按既有解析入口读(同一份 parseDotEnv + 同一份 `env:"…"` 标签解码器),优先级与
|
||
// Load 逐字一致(进程环境 > `.env` > 标签默认),且读不出时明确报错——绝不回一份
|
||
// 空快照冒充「env 什么都没声明」。
|
||
//
|
||
// 它刻意只解 LLM 子树:整份 Load 会在缺 JWT_SECRET 这类必填项时失败,而本读取不需要
|
||
// 它们(第一个用例就把这条钉住)。
|
||
|
||
func TestLoadLLMDefaultsReadsEnvFileWithoutRequiredConfig(t *testing.T) {
|
||
// 这份 `.env` 故意不含 JWT_SECRET 等必填项:本读取只解 LLM 子树,不该因此失败。
|
||
path := writeEnvFile(t, strings.Join([]string{
|
||
"# 注释与空行照旧被忽略",
|
||
"LLM_BASE_URL=https://env.example/v1",
|
||
"LLM_API_KEY=sk-env-plaintext",
|
||
"LLM_MODEL=env-model",
|
||
"LLM_CONTEXT_WINDOW=131072",
|
||
"ANTHROPIC_MODEL=env-claude",
|
||
"",
|
||
}, "\n"))
|
||
|
||
llm, err := LoadLLMDefaults(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadLLMDefaults: %v", err)
|
||
}
|
||
if llm.Provider != "openai" || llm.BaseURL != "https://env.example/v1" ||
|
||
llm.APIKey != "sk-env-plaintext" || llm.Model != "env-model" || llm.ContextWindow != 131072 {
|
||
t.Fatalf("llm = %+v, want `.env` 里逐字声明的扁平块(LLM_PROVIDER 未声明 ⇒ 标签默认 openai)", llm)
|
||
}
|
||
if llm.AnthropicModel != "env-claude" {
|
||
t.Fatalf("AnthropicModel = %q, want env-claude(同一份解码器覆盖全部 LLM_*/ANTHROPIC_* 标签)", llm.AnthropicModel)
|
||
}
|
||
}
|
||
|
||
func TestLoadLLMDefaultsProcessEnvWinsOverEnvFile(t *testing.T) {
|
||
path := writeEnvFile(t, "LLM_MODEL=from-file\n")
|
||
t.Setenv("LLM_MODEL", "from-process")
|
||
// 该键没被声明:必须仍是零值(空),不能被 `.env` 的其它行牵连。
|
||
t.Setenv("LLM_BASE_URL", "")
|
||
|
||
llm, err := LoadLLMDefaults(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadLLMDefaults: %v", err)
|
||
}
|
||
if llm.Model != "from-process" {
|
||
t.Fatalf("Model = %q, want from-process(优先级必须与 Load 一致:进程环境 > .env)", llm.Model)
|
||
}
|
||
}
|
||
|
||
func TestLoadLLMDefaultsUnreadableSourceFails(t *testing.T) {
|
||
// 目录不是可读的 `.env`:读取必须明确报错,而不是静默回一份空声明。
|
||
dir := t.TempDir()
|
||
if _, err := LoadLLMDefaults(dir); err == nil {
|
||
t.Fatal("对目录调用 LoadLLMDefaults 应报错, want 明确错误")
|
||
}
|
||
|
||
// 不存在的文件按 `.env` 的开发期约定容忍(parseDotEnv 的既有语义):结果是
|
||
// 「只按进程环境与标签默认」的一份快照,而不是错误。
|
||
missing := filepath.Join(t.TempDir(), "absent.env")
|
||
llm, err := LoadLLMDefaults(missing)
|
||
if err != nil {
|
||
t.Fatalf("文件缺失应容忍(.env 只是开发期便利): %v", err)
|
||
}
|
||
if llm.Provider != "openai" || llm.BaseURL != "" {
|
||
t.Fatalf("llm = %+v, want 标签默认 + 空串(没有 `.env` 可读)", llm)
|
||
}
|
||
}
|
||
|
||
func TestLoadLLMDefaultsRejectsNegativeContextWindow(t *testing.T) {
|
||
path := writeEnvFile(t, "LLM_CONTEXT_WINDOW=-1\n")
|
||
if _, err := LoadLLMDefaults(path); err == nil {
|
||
t.Fatal("负数窗口应报错(与 Load 同一口径:窗口没有负数语义)")
|
||
}
|
||
}
|