- 主人原话:「.env 只是基础入口,生产环境下应该在每次启动进程时从 .env 把变量(包括后续更新过的)
写入面板专用配置文件,而不是仅依赖 .env 这个脆弱的文件。」⇒ 根治「每加一种能力就再加一个 env 键」
- 规则(主会话裁定,已写入 02 回执):
· **组集合**:面板配置非空 ⇒ 完全由面板决定(env 不新增/不复活被删组/不重排);缺失/空/[]/null ⇒ 由 .env 播种(=今天 env-only 行为)
· **键级**:面板缺 provider/baseUrl/apiKey/model 某键 ⇒ 用 .env 补;**面板已有该键(含显式 ""/null/false)⇒ 面板优先,绝不覆盖**
· 判定用原始 JSON 的**键存在性**(故显式空值也算声明);`len(Filled)==0` ⇒ 一个字节都不动(幂等)
· 面板不可读/解析失败/补齐后不合法 ⇒ **不覆盖原文件**(保人工可救),只 WARN;写盘失败不阻断启动
· 带 omitempty/omitzero 的 fallback/multimodal/reasoning.kind **不在补齐表内**(面板清空与未声明字节相同,
补回会撤销面板操作);它们仍以面板为准、文件缺失/无效时由 .env 兜底
- 落点:`internal/config/runtimeconfig/materialize.go`(导出 Materialize + 诊断 Result/Fill)+ 组合根
`cmd/cyrene/models_materialize.go`,在 `NewRuntimeLLM` **之前**跑一次(**不在热重载路径上**,否则面板刚清空的键会被立刻补回)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 全绿 + linux/darwin 交叉编译绿;6 条真断言
(面板值胜出且字节不变 / 缺键补齐并落盘 / 被删组不复活 / 五态播种 / 幂等逐字节相同 /
multimodal 回归两路径);**4 条变异反证**(一律 env 覆盖、只首启播种、组集合由 env 决定、无可补也重写)均已逐字节还原;
另加端到端:`.env`→启动补齐→真 NewRuntimeLLM→真面板写路径改成 panel-model→再启动 ⇒ 仍是面板值
- 契约面:REST/WS/admin 零变更(只改本地面板配置文件 models.json 的语义);待落 Docs v0.59 一条
278 lines
11 KiB
Go
278 lines
11 KiB
Go
package runtimeconfig
|
||
|
||
import (
|
||
"bytes"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件钉住启动补齐的**边界**:显式清空与 omitempty 键的面板优先、组集合与顺序
|
||
// 由面板全权决定、解析失败/补齐后不合法时绝不覆盖原文件、以及日志可溯源且不泄凭据;
|
||
// 并把 `LLM_MULTIMODAL` 的既有语义在补齐之后原样钉住。
|
||
|
||
// TestMaterializeMultimodalRegression 是 `LLM_MULTIMODAL`(4adfc9a)的回归:补齐落地
|
||
// 之后,它在**两条路径**下的语义与既有断言(multimodal_env_test.go)逐条一致——
|
||
//
|
||
// 路径 A(面板配置缺失/为空 ⇒ env 播种):本键由 GroupFromEnv 带进合成组,落盘可见;
|
||
// 路径 B(面板配置存在 ⇒ 面板优先):本键**不在补齐表内**,面板未声明就是未声明,
|
||
// `.env` 的 LLM_MULTIMODAL=true 不得替面板作答(否则面板的勾选会被 .env 顶掉)。
|
||
func TestMaterializeMultimodalRegression(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
env.Multimodal = true
|
||
|
||
t.Run("面板缺失时由 env 播种出 multimodal", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "models.json")
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if !res.Groups[0].Multimodal {
|
||
t.Fatal("播种路径:Multimodal = false, want true(GroupFromEnv 既有语义)")
|
||
}
|
||
if !strings.Contains(readRaw(t, path), `"multimodal": true`) {
|
||
t.Fatalf("播种落盘文件缺 multimodal: %s", readRaw(t, path))
|
||
}
|
||
})
|
||
|
||
t.Run("面板存在时 env 不得补 multimodal", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[
|
||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model"}
|
||
]`)
|
||
before := readRaw(t, path)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if len(res.Filled) != 0 {
|
||
t.Fatalf("Filled = %v, want 空(multimodal 不在补齐表内)", fillStrings(res.Filled))
|
||
}
|
||
groups, warn := LoadModels(path, env)
|
||
if warn != nil {
|
||
t.Fatalf("LoadModels: warn = %v", warn)
|
||
}
|
||
if groups[0].Multimodal {
|
||
t.Fatal("env 的 LLM_MULTIMODAL=true 替面板作了答, want 组文件优先")
|
||
}
|
||
if res.Written || readRaw(t, path) != before {
|
||
t.Fatal("文件不得因 env 的 multimodal 声明而改动")
|
||
}
|
||
})
|
||
|
||
t.Run("面板声明 true 时保持 true", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[
|
||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model","multimodal":true}
|
||
]`)
|
||
off := materializeEnv()
|
||
res, err := Materialize(path, off, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if !res.Groups[0].Multimodal {
|
||
t.Fatal("面板显式 true 被 env 的未声明顶掉了, want 面板优先")
|
||
}
|
||
if res.Written {
|
||
t.Fatal("面板已有该键且无需补齐时不得改写文件")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestMaterializeMultimodalFromProcessEnv 走一遍真实读路径的播种:进程 env →
|
||
// config.Load → cfg.LLM.Multimodal → Materialize 播种出的组。用 t.Setenv,故不与
|
||
// 并行测试同跑(config 包同口径)。
|
||
func TestMaterializeMultimodalFromProcessEnv(t *testing.T) {
|
||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||
t.Setenv("LLM_MULTIMODAL", "true")
|
||
t.Setenv("LLM_BASE_URL", "https://env.example/v1")
|
||
t.Setenv("LLM_API_KEY", "sk-env-key")
|
||
t.Setenv("LLM_MODEL", "env-model")
|
||
cfg, err := config.Load("")
|
||
if err != nil {
|
||
t.Fatalf("config.Load: %v", err)
|
||
}
|
||
path := filepath.Join(t.TempDir(), "models.json")
|
||
res, err := Materialize(path, cfg.LLM, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if !res.Seeded || !res.Groups[0].Multimodal {
|
||
t.Fatalf("Seeded=%v Multimodal=%v, want 播种且收图能力为 true", res.Seeded, res.Groups[0].Multimodal)
|
||
}
|
||
if !strings.Contains(readRaw(t, path), `"multimodal": true`) {
|
||
t.Fatalf("落盘文件缺 multimodal: %s", readRaw(t, path))
|
||
}
|
||
}
|
||
|
||
// TestMaterializeKeepsPanelExplicitEmpty 回答裁决里那条存疑口径:**面板显式清空某键
|
||
// ⇒ 面板优先、env 不补**。`apiKey` 两处取证——空串与 null 都是「显式声明为空」,都在
|
||
// 原始键集合里,因此都不得被 env 的值填回去。
|
||
func TestMaterializeKeepsPanelExplicitEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
for _, tc := range []struct {
|
||
name string
|
||
raw string
|
||
}{
|
||
{name: "空串", raw: `[{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"","model":"panel-model"}]`},
|
||
{name: "null", raw: `[{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":null,"model":"panel-model"}]`},
|
||
} {
|
||
path := writeModelsFile(t, tc.raw)
|
||
before := readRaw(t, path)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("%s: Materialize: %v", tc.name, err)
|
||
}
|
||
if res.Groups[0].APIKey != "" {
|
||
t.Fatalf("%s: APIKey = %q, want 保持面板的显式空值", tc.name, res.Groups[0].APIKey)
|
||
}
|
||
if len(res.Filled) != 0 || res.Written || readRaw(t, path) != before {
|
||
t.Fatalf("%s: Filled=%v Written=%v, want 面板优先且不动文件", tc.name, fillStrings(res.Filled), res.Written)
|
||
}
|
||
if got := fillStrings(res.PanelWins); !equalStrings(got, []string{"openai.provider", "openai.baseUrl", "openai.apiKey", "openai.model"}) {
|
||
t.Fatalf("%s: PanelWins = %v, want 面板四个键全部保留(含显式空的 apiKey)", tc.name, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMaterializeLeavesOmitemptyKeysToPanel 钉住补齐表的**刻意留白**:`fallback` /
|
||
// `multimodal` / `reasoning.kind` 带 omitempty/omitzero,在 models.json 里「面板清空
|
||
// 该键」与「从未写过该键」是同一个字节形态(键直接不写)。若把它们纳入补齐,面板里
|
||
// 清掉后重启就会被 `.env` 静默补回——正是裁决禁止的「重启打回面板」。故它们一律
|
||
// 留给面板:面板配置缺失/无效时仍由 GroupFromEnv 兜底(见 multimodal 回归那组)。
|
||
func TestMaterializeLeavesOmitemptyKeysToPanel(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
env.Multimodal = true
|
||
env.ReasoningKind = ReasoningKindVLLM
|
||
path := writeModelsFile(t, `[
|
||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model"}
|
||
]`)
|
||
before := readRaw(t, path)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if len(res.Filled) != 0 || res.Written || readRaw(t, path) != before {
|
||
t.Fatalf("Filled=%v Written=%v, want 三个 omitempty 键都不补、文件不动", fillStrings(res.Filled), res.Written)
|
||
}
|
||
g := res.Groups[0]
|
||
if g.Fallback != "" || g.Multimodal || g.Reasoning.Kind != "" {
|
||
t.Fatalf("组 = %+v, want fallback/multimodal/reasoning.kind 全部保持面板的「未声明」", g)
|
||
}
|
||
}
|
||
|
||
// TestMaterializeKeepsPanelGroupSetAndOrder:组集合与顺序完全由面板决定,`.env` 既不
|
||
// 新增(哪怕 env.Provider 指向另一种协议)、也不复活、更不重排;补齐时按**组自己的**
|
||
// provider 选 env 族,而不是按 env.Provider。
|
||
func TestMaterializeKeepsPanelGroupSetAndOrder(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
env.Provider = ProviderAnthropic // 播种会合成 anthropic 组,补齐不应受它影响
|
||
path := writeModelsFile(t, `[
|
||
{"id":"a","provider":"openai","baseUrl":"https://panel-a/v1","apiKey":"sk-a","model":"model-a"},
|
||
{"id":"b","provider":"openai","baseUrl":"https://panel-b/v1","apiKey":"sk-b","model":"model-b"}
|
||
]`)
|
||
before := readRaw(t, path)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if len(res.Groups) != 2 || res.Groups[0].ID != "a" || res.Groups[1].ID != "b" {
|
||
t.Fatalf("组集合 = %+v, want 面板的两个组原序保留", res.Groups)
|
||
}
|
||
if res.Seeded || res.Written || readRaw(t, path) != before {
|
||
t.Fatal("面板非空且无键可补时不得播种也不得改文件")
|
||
}
|
||
|
||
// openai 组缺 apiKey ⇒ 取**扁平块**的值(env.Provider 是 anthropic 也不改族)。
|
||
fillPath := writeModelsFile(t, `[{"id":"a","provider":"openai","baseUrl":"https://panel-a/v1","model":"model-a"}]`)
|
||
res, err = Materialize(fillPath, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if got := res.Groups[0].APIKey; got != "sk-env-key" {
|
||
t.Fatalf("APIKey = %q, want 扁平块的 sk-env-key(按组自己的 provider 选族)", got)
|
||
}
|
||
}
|
||
|
||
// TestMaterializeDoesNotOverwriteBadPanel:解析失败或补齐后仍不合法的面板配置一律
|
||
// **不落盘**——损坏的配置可以人工救回,用 `.env` 盖掉它会连人写的组一起丢失。结论与
|
||
// LoadModels 一致(回退 env 合成组),只多一条可诊断的 Warn。
|
||
func TestMaterializeDoesNotOverwriteBadPanel(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
for _, tc := range []struct {
|
||
name string
|
||
raw string
|
||
}{
|
||
{name: "不是 JSON", raw: `{ not json`},
|
||
{name: "顶层不是数组", raw: `{"id":"g"}`},
|
||
{name: "字段类型不对", raw: `[{"id":"g","provider":"openai","baseUrl":"https://p","model":"m","maxTokens":"abc"}]`},
|
||
{name: "补齐后仍不合法", raw: `[{"id":"g","provider":"gemini","model":"m"}]`},
|
||
} {
|
||
path := writeModelsFile(t, tc.raw)
|
||
before := readRaw(t, path)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("%s: Materialize 不该返回错(写盘并未尝试): %v", tc.name, err)
|
||
}
|
||
if res.Warn == nil {
|
||
t.Fatalf("%s: Warn = nil, want 一条可诊断告警", tc.name)
|
||
}
|
||
if res.Written || readRaw(t, path) != before {
|
||
t.Fatalf("%s: 原文件被改写了", tc.name)
|
||
}
|
||
if len(res.Groups) != 1 || res.Groups[0].APIKey != env.APIKey {
|
||
t.Fatalf("%s: 兜底组 = %+v, want env 合成组(与 LoadModels 口径一致)", tc.name, res.Groups)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMaterializeLogsProvenanceWithoutCredentials:补齐动作必须可诊断——日志带
|
||
// source=(config/runtimeconfig)、说清补了哪些键(组 id + 键名);且**凭据绝不进日志**。
|
||
func TestMaterializeLogsProvenanceWithoutCredentials(t *testing.T) {
|
||
t.Parallel()
|
||
const secret = "sk-env-secret-key"
|
||
env := materializeEnv()
|
||
env.APIKey = secret
|
||
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||
path := writeModelsFile(t, `[{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","model":"panel-model"}]`)
|
||
if _, err := Materialize(path, env, log); err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, `"source":"config/runtimeconfig"`) {
|
||
t.Fatalf("日志缺来源标注: %s", out)
|
||
}
|
||
if !strings.Contains(out, "openai.apiKey") || !strings.Contains(out, `"count":1`) {
|
||
t.Fatalf("日志没说清补了哪个键: %s", out)
|
||
}
|
||
if strings.Contains(out, secret) {
|
||
t.Fatalf("日志泄漏了凭据: %s", out)
|
||
}
|
||
|
||
// 播种路径同样:只报组数,不打印任何组内取值。
|
||
buf.Reset()
|
||
seedPath := filepath.Join(t.TempDir(), "models.json")
|
||
if _, err := Materialize(seedPath, env, log); err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
seedOut := buf.String()
|
||
if !strings.Contains(seedOut, `"source":"config/runtimeconfig"`) || strings.Contains(seedOut, secret) {
|
||
t.Fatalf("播种日志异常: %s", seedOut)
|
||
}
|
||
if _, err := os.Stat(seedPath); err != nil {
|
||
t.Fatalf("播种应落盘: %v", err)
|
||
}
|
||
}
|