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

278 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 (
"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)
}
}