feat(config,cyrene): .env → 面板配置的启动期 materialize(主人裁决:面板是一等来源,.env 退回入口/播种)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 主人原话:「.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 一条
This commit is contained in:
AskaEth committed 2026-10-04 16:39:33 +08:00
1 parent 1728addcfe
commit 910d154d35
6 files changed
+1016 -1

No files matched your search

@@ -0,0 +1,314 @@
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("第二次改动了文件字节")
}
})
}