Files
AskaEth 910d154d35
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,cyrene): .env → 面板配置的启动期 materialize(主人裁决:面板是一等来源,.env 退回入口/播种)
- 主人原话:「.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 一条
2026-10-04 16:39:33 +08:00

315 lines
11 KiB
Go
Raw Permalink 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 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("第二次改动了文件字节")
}
})
}