Files
Cyrene_v2/internal/config/llm_defaults_test.go
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .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 生效。
2026-10-04 21:33:11 +08:00

82 lines
3.2 KiB
Go
Raw 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 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 同一口径:窗口没有负数语义)")
}
}