- 主人原话:「.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 一条
315 lines
11 KiB
Go
315 lines
11 KiB
Go
package runtimeconfig
|
||
|
||
import (
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件钉住「`.env` = 默认/播种层,models.json = 一等来源」的启动补齐规则(主人
|
||
// 裁决,实现见 materialize.go):组集合由面板全权决定、键级以面板优先、每个键只在
|
||
// 面板**缺**它时才由 `.env` 补,且整件事幂等可重复。
|
||
|
||
// materializeEnv 是补齐用例的基线 `.env`:扁平 OpenAI 块 + 一块 anthropic 配置,
|
||
// 每个值都与面板文件里的测试值**不同**,好让「面板优先」与「env 补齐」一眼可辨。
|
||
func materializeEnv() config.LLMConfig {
|
||
return config.LLMConfig{
|
||
Provider: ProviderOpenAI,
|
||
BaseURL: "https://env.example/v1",
|
||
APIKey: "sk-env-key",
|
||
Model: "env-model",
|
||
Fallback: "env-fallback",
|
||
AnthropicBaseURL: "https://env-anthropic.example",
|
||
AnthropicAPIKey: "sk-env-anthropic",
|
||
AnthropicModel: "env-claude",
|
||
AnthropicFallback: "env-claude-mini",
|
||
}
|
||
}
|
||
|
||
// discardLog 让补齐用例的日志不进测试输出(断言日志内容另开一例)。
|
||
func discardLog() *slog.Logger {
|
||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|
||
|
||
// readRaw 读回文件原文:幂等与「不覆盖」两类断言都必须比字节,不能只看解出来的组。
|
||
func readRaw(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)
|
||
}
|
||
|
||
// fillStrings 把补齐记录渲染成 "组 id.键名",便于直接比对期望集合。
|
||
func fillStrings(fills []Fill) []string {
|
||
out := make([]string, 0, len(fills))
|
||
for _, f := range fills {
|
||
out = append(out, f.String())
|
||
}
|
||
return out
|
||
}
|
||
|
||
// equalStrings 比对两份字符串集合(顺序敏感,补齐顺序即 groupFillSpecs 顺序)。
|
||
func equalStrings(got, want []string) bool {
|
||
if len(got) != len(want) {
|
||
return false
|
||
}
|
||
for i := range got {
|
||
if got[i] != want[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// TestMaterializePanelKeyBeatsEnv 是本任务的核心:面板**已有**该键时,即使 `.env`
|
||
// 的值不同,也**必须**保留面板值(否则就是「面板改一次、重启一次被 .env 打回去」)。
|
||
// 同时断言本次不改动文件一个字节。
|
||
func TestMaterializePanelKeyBeatsEnv(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[
|
||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model"}
|
||
]`)
|
||
before := readRaw(t, path)
|
||
res, err := Materialize(path, materializeEnv(), discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if res.Warn != nil {
|
||
t.Fatalf("Warn = %v, want nil", res.Warn)
|
||
}
|
||
if len(res.Filled) != 0 {
|
||
t.Fatalf("Filled = %v, want 空(面板四个键都在)", fillStrings(res.Filled))
|
||
}
|
||
want := []string{"openai.provider", "openai.baseUrl", "openai.apiKey", "openai.model"}
|
||
if got := fillStrings(res.PanelWins); !equalStrings(got, want) {
|
||
t.Fatalf("PanelWins = %v, want %v", got, want)
|
||
}
|
||
g := res.Groups[0]
|
||
if g.BaseURL != "https://panel.example/v1" || g.APIKey != "sk-panel" || g.Model != "panel-model" {
|
||
t.Fatalf("组 = %+v, want 面板值原样保留", g)
|
||
}
|
||
if res.Written || readRaw(t, path) != before {
|
||
t.Fatal("无键可补时不得改动文件(连重新格式化都不做)")
|
||
}
|
||
}
|
||
|
||
// TestMaterializeFillsMissingKeysFromEnv 覆盖键级补齐的三种形态:面板缺一个可选键
|
||
// (apiKey)、缺一个必填键(provider)、以及 anthropic 组必须取 anthropic 那块 env
|
||
// 而不是扁平块。补齐后必须**真的落盘**。
|
||
func TestMaterializeFillsMissingKeysFromEnv(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
|
||
t.Run("缺 apiKey", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[
|
||
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","model":"panel-model"}
|
||
]`)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if want := []string{"openai.apiKey"}; !equalStrings(fillStrings(res.Filled), want) {
|
||
t.Fatalf("Filled = %v, want %v", fillStrings(res.Filled), want)
|
||
}
|
||
if !res.Written {
|
||
t.Fatal("补齐后必须落盘")
|
||
}
|
||
g := res.Groups[0]
|
||
if g.APIKey != "sk-env-key" || g.BaseURL != "https://panel.example/v1" || g.Model != "panel-model" {
|
||
t.Fatalf("组 = %+v, want 面板值 + env 补的 apiKey", g)
|
||
}
|
||
// 落盘后从文件重读,仍是同一份合并结果。
|
||
groups, warn := LoadModels(path, env)
|
||
if warn != nil {
|
||
t.Fatalf("LoadModels: warn = %v", warn)
|
||
}
|
||
if groups[0].APIKey != "sk-env-key" || groups[0].Model != "panel-model" {
|
||
t.Fatalf("重读 = %+v, want 合并结果已持久化", groups[0])
|
||
}
|
||
})
|
||
|
||
t.Run("缺 provider", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[{"id":"g","baseUrl":"https://panel.example/v1","model":"panel-model"}]`)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if want := []string{"g.provider", "g.apiKey"}; !equalStrings(fillStrings(res.Filled), want) {
|
||
t.Fatalf("Filled = %v, want %v", fillStrings(res.Filled), want)
|
||
}
|
||
if res.Groups[0].Provider != ProviderOpenAI || res.Groups[0].APIKey != "sk-env-key" {
|
||
t.Fatalf("组 = %+v, want provider/apiKey 由 env 补齐", res.Groups[0])
|
||
}
|
||
if !strings.Contains(readRaw(t, path), `"provider": "openai"`) {
|
||
t.Fatalf("落盘文件缺 provider: %s", readRaw(t, path))
|
||
}
|
||
})
|
||
|
||
t.Run("anthropic 组取 anthropic 那块 env", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[
|
||
{"id":"claude","provider":"anthropic","baseUrl":"https://panel.example"}
|
||
]`)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if want := []string{"claude.apiKey", "claude.model"}; !equalStrings(fillStrings(res.Filled), want) {
|
||
t.Fatalf("Filled = %v, want %v", fillStrings(res.Filled), want)
|
||
}
|
||
g := res.Groups[0]
|
||
if g.APIKey != "sk-env-anthropic" || g.Model != "env-claude" {
|
||
t.Fatalf("组 = %+v, want anthropic 块的 apiKey/model(不能取扁平块)", g)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestMaterializeNeverResurrectsPanelDeletedGroup:面板把某个组删掉后,即使 `.env`
|
||
// 仍然声明着它,重启也不得把它复活(组集合完全由面板决定)。
|
||
func TestMaterializeNeverResurrectsPanelDeletedGroup(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv() // env.Provider = openai,播种时本会合成 id=openai 的组
|
||
path := writeModelsFile(t, `[
|
||
{"id":"custom","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.Groups) != 1 || res.Groups[0].ID != "custom" {
|
||
t.Fatalf("组集合 = %+v, want 只剩面板的 custom", res.Groups)
|
||
}
|
||
for _, g := range res.Groups {
|
||
if g.ID == ProviderOpenAI {
|
||
t.Fatal("`.env` 声明的 openai 组被复活了")
|
||
}
|
||
}
|
||
if res.Seeded || res.Written || readRaw(t, path) != before {
|
||
t.Fatal("面板非空时既不该播种也不该改动文件")
|
||
}
|
||
}
|
||
|
||
// TestMaterializeSeedsWhenPanelMissingOrEmpty:面板配置缺失/为空 ⇒ 由 `.env` 播种出
|
||
// 合成组(= GroupFromEnv 的既有行为),并落盘成面板的一等来源。`.env` 自己没配全时
|
||
// 合成组不合法,此时**不写盘**(不制造一份无效配置),但仍报告播种结论。
|
||
func TestMaterializeSeedsWhenPanelMissingOrEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
want := GroupFromEnv(env)
|
||
|
||
missing := filepath.Join(t.TempDir(), "models.json")
|
||
cases := []struct {
|
||
name string
|
||
path string
|
||
}{
|
||
{name: "文件不存在", path: missing},
|
||
{name: "空文件", path: writeModelsFile(t, "")},
|
||
}
|
||
for _, tc := range cases {
|
||
res, err := Materialize(tc.path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("%s: Materialize: %v", tc.name, err)
|
||
}
|
||
if !res.Seeded || !res.Written || res.Warn != nil {
|
||
t.Fatalf("%s: Seeded=%v Written=%v Warn=%v, want 播种并落盘", tc.name, res.Seeded, res.Written, res.Warn)
|
||
}
|
||
if len(res.Groups) != 1 {
|
||
t.Fatalf("%s: 组数 = %d, want 1", tc.name, len(res.Groups))
|
||
}
|
||
if got := res.Groups[0]; got.BaseURL != want[0].BaseURL || got.APIKey != want[0].APIKey ||
|
||
got.Model != want[0].Model || got.ID != want[0].ID {
|
||
t.Fatalf("%s: 组 = %+v, want env 合成组 %+v", tc.name, got, want[0])
|
||
}
|
||
groups, warn := LoadModels(tc.path, env)
|
||
if warn != nil {
|
||
t.Fatalf("%s: LoadModels: warn = %v", tc.name, warn)
|
||
}
|
||
if groups[0].APIKey != want[0].APIKey {
|
||
t.Fatalf("%s: 落盘后重读 = %+v, want 合成组", tc.name, groups[0])
|
||
}
|
||
}
|
||
|
||
for _, blank := range []string{"[]", " \n\t", "null"} {
|
||
path := writeModelsFile(t, blank)
|
||
res, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("%q: Materialize: %v", blank, err)
|
||
}
|
||
if !res.Seeded || !res.Written {
|
||
t.Fatalf("%q: Seeded=%v Written=%v, want 按「空」播种", blank, res.Seeded, res.Written)
|
||
}
|
||
}
|
||
|
||
// `.env` 没配全 ⇒ 合成组不合法 ⇒ 不落盘,只报 Warn。
|
||
incomplete := filepath.Join(t.TempDir(), "models.json")
|
||
res, err := Materialize(incomplete, config.LLMConfig{}, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("Materialize: %v", err)
|
||
}
|
||
if !res.Seeded || res.Written {
|
||
t.Fatalf("Seeded=%v Written=%v, want 只播种不落盘", res.Seeded, res.Written)
|
||
}
|
||
if res.Warn == nil {
|
||
t.Fatal("Warn = nil, want 一份「.env 自己也没配全」的告警")
|
||
}
|
||
if _, statErr := os.Stat(incomplete); !os.IsNotExist(statErr) {
|
||
t.Fatal("不合法的合成组不得落盘")
|
||
}
|
||
}
|
||
|
||
// TestMaterializeIsIdempotent:连跑两次,第二次必须**零改动**(字节级),无论第一次
|
||
// 走的是播种路径还是补齐路径。
|
||
func TestMaterializeIsIdempotent(t *testing.T) {
|
||
t.Parallel()
|
||
env := materializeEnv()
|
||
|
||
t.Run("播种路径", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "models.json")
|
||
first, err := Materialize(path, env, discardLog())
|
||
if err != nil || !first.Written {
|
||
t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err)
|
||
}
|
||
afterFirst := readRaw(t, path)
|
||
second, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("第二次: %v", err)
|
||
}
|
||
if second.Written || second.Seeded || len(second.Filled) != 0 {
|
||
t.Fatalf("第二次 = %+v, want 零改动", second)
|
||
}
|
||
if readRaw(t, path) != afterFirst {
|
||
t.Fatal("第二次改动了文件字节")
|
||
}
|
||
})
|
||
|
||
t.Run("补齐路径", func(t *testing.T) {
|
||
t.Parallel()
|
||
path := writeModelsFile(t, `[{"id":"g","baseUrl":"https://panel.example/v1","model":"m"}]`)
|
||
if _, err := Materialize(path, env, discardLog()); err != nil {
|
||
t.Fatalf("第一次: %v", err)
|
||
}
|
||
afterFirst := readRaw(t, path)
|
||
second, err := Materialize(path, env, discardLog())
|
||
if err != nil {
|
||
t.Fatalf("第二次: %v", err)
|
||
}
|
||
if second.Written || len(second.Filled) != 0 {
|
||
t.Fatalf("第二次 = %+v, want 零改动", second)
|
||
}
|
||
if readRaw(t, path) != afterFirst {
|
||
t.Fatal("第二次改动了文件字节")
|
||
}
|
||
})
|
||
}
|