diff --git a/cmd/cyrene/models_materialize.go b/cmd/cyrene/models_materialize.go new file mode 100644 index 0000000..2d054a4 --- /dev/null +++ b/cmd/cyrene/models_materialize.go @@ -0,0 +1,29 @@ +package main + +import ( + "log/slog" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// materializeModels 在进程启动时跑一次「`.env` 默认层 → models.json 补齐」。 +// +// 语义(主人裁决,规则见 runtimeconfig.Materialize):`.env` 只是基础入口/播种层, +// models.json(面板专用配置文件)才是一等来源。每次启动把 `.env` 的 LLM 块补进面板 +// 配置里**缺失**的键,面板已有的键(含显式空值)绝不覆盖,组集合完全由面板决定 +// (面板删掉的组不会因 `.env` 仍声明它而复活);面板配置缺失/为空时才由 `.env` +// 播种出合成组(既有行为)。幂等,重复启动不再改动文件。 +// +// 失败一律只记日志、**不阻断启动**:模型栈的既有兜底(LoadModels 回退 `.env`)仍然 +// 兜住,配置文件的一次写失败不该让服务起不来。 +func materializeModels(path string, env config.LLMConfig, log *slog.Logger) { + res, err := runtimeconfig.Materialize(path, env, log) + if err != nil { + log.Warn("models.json 补齐失败(模型栈仍按现状运行)", "path", path, "error", err) + return + } + if res.Warn != nil { + log.Warn("models.json 补齐未完成(模型栈仍按现状运行)", "path", path, "error", res.Warn) + } +} diff --git a/cmd/cyrene/models_materialize_test.go b/cmd/cyrene/models_materialize_test.go new file mode 100644 index 0000000..4e201a4 --- /dev/null +++ b/cmd/cyrene/models_materialize_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "bytes" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 启动补齐接线(materializeModels)的契约:成功时面板专用配置文件在**模型栈构建 +// 之前**就位(NewRuntimeLLM 紧接着读它);任何失败都只记日志,**不阻断启动**。 + +func TestMaterializeModelsCreatesPanelFileAtBoot(t *testing.T) { + path := filepath.Join(t.TempDir(), "models.json") + env := config.LLMConfig{Provider: "openai", BaseURL: "https://x/v1", APIKey: "sk-x", Model: "m"} + var buf bytes.Buffer + materializeModels(path, env, slog.New(slog.NewJSONHandler(&buf, nil))) + + if _, err := os.Stat(path); err != nil { + t.Fatalf("启动补齐应写出 models.json: %v", err) + } + groups, warn := runtimeconfig.LoadModels(path, env) + if warn != nil { + t.Fatalf("LoadModels: warn = %v", warn) + } + if len(groups) != 1 || groups[0].Model != "m" || groups[0].APIKey != "sk-x" { + t.Fatalf("组 = %+v, want .env 播种的合成组", groups) + } + if strings.Contains(buf.String(), "sk-x") { + t.Fatalf("启动日志泄漏了凭据: %s", buf.String()) + } +} + +func TestMaterializeModelsNeverBlocksBoot(t *testing.T) { + // 路径不可读/不可写时只记日志:模型栈的既有兜底(LoadModels 回退 .env)仍成立, + // 配置文件的一次失败不该让服务起不来。 + var buf bytes.Buffer + materializeModels(t.TempDir(), config.LLMConfig{Provider: "openai"}, slog.New(slog.NewJSONHandler(&buf, nil))) + if !strings.Contains(buf.String(), "models.json 补齐") { + t.Fatalf("失败时应有可诊断日志: %s", buf.String()) + } +} + +// TestModelsBootPathEndToEnd 复现主人的原始场景(去掉 DB/HTTP 接线,端口与 data/ 都不碰): +// `.env` → 启动补齐 → 面板专用配置文件 → **真**模型栈(NewRuntimeLLM);面板改一次 +// (经真面板保存用的 runtimeconfig.SaveModels 写回),**再启动一次**不得被 `.env` 打回。 +func TestModelsBootPathEndToEnd(t *testing.T) { + path := filepath.Join(t.TempDir(), "models.json") + env := config.LLMConfig{Provider: "openai", BaseURL: "https://env.example/v1", APIKey: "sk-env", Model: "env-model"} + log := slog.New(slog.NewJSONHandler(io.Discard, nil)) + + // 第一次启动:面板文件不存在 ⇒ 由 .env 播种,模型栈直接读到 env 的模型名。 + materializeModels(path, env, log) + stack := model.NewRuntimeLLM(path, env, log) + if got := stack.Model(); got != "env-model" { + t.Fatalf("首次启动模型名 = %q, want env 的 env-model", got) + } + + // 面板把该组改成 panel-model(与面板保存端点同一条写路径),随后热重载。 + groups := stack.Groups() + groups[0].Model = "panel-model" + groups[0].APIKey = "sk-panel" + if err := runtimeconfig.SaveModels(path, groups); err != nil { + t.Fatalf("面板保存: %v", err) + } + + // 第二次启动:补齐只看**缺失**的键,面板的改动不得被打回 .env 值。 + materializeModels(path, env, log) + restarted := model.NewRuntimeLLM(path, env, log) + if got := restarted.Model(); got != "panel-model" { + t.Fatalf("重启后模型名 = %q, want 面板改的 panel-model(被 .env 打回即失败)", got) + } + if got := restarted.Group().APIKey; got != "sk-panel" { + t.Fatalf("重启后 apiKey = %q, want 面板的 sk-panel", got) + } +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 901cf30..3dc5bae 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -123,7 +123,12 @@ func (a *app) wireRuntime() error { // back to .env) and the panel save endpoint reloads it without a restart // (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack // implements it) for the channel-bridge review model (design/12 §6 策略四). - runtimeStack := model.NewRuntimeLLM(runtimeconfig.DefaultPath(resolveDataDir(a.cfg)), a.cfg.LLM, a.log) + modelsPath := runtimeconfig.DefaultPath(resolveDataDir(a.cfg)) + // 启动即补齐一次(主人裁决):`.env` 只是默认/播种层,models.json 才是面板的 + // 一等来源——把 `.env` 补齐进面板配置里缺失的键(面板已有的键绝不覆盖,组集合 + // 完全由面板决定),幂等;失败只记日志、不阻断启动。 + materializeModels(modelsPath, a.cfg.LLM, a.log) + runtimeStack := model.NewRuntimeLLM(modelsPath, a.cfg.LLM, a.log) a.modelAdapter = runtimeStack a.llm = runtimeStack diff --git a/internal/config/runtimeconfig/materialize.go b/internal/config/runtimeconfig/materialize.go new file mode 100644 index 0000000..4313e38 --- /dev/null +++ b/internal/config/runtimeconfig/materialize.go @@ -0,0 +1,307 @@ +package runtimeconfig + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// 本文件落地「`.env` = 默认/播种层,`models.json`(面板专用配置文件)= 一等来源」 +// 这条结构性要求(主人裁决):进程**每次启动**跑一次 Materialize,把 `.env` 的 LLM +// 块写进面板配置文件,使面板配置自足,而不是每次运行都依赖 `.env` 这个脆弱的文件。 +// 规则幂等,分两个层面: +// +// - **组级**:面板配置**非空** ⇒ 组集合**完全由面板决定**——env 不新增、不复活 +// 任何组,也不删除面板的组;面板配置**缺失/为空** ⇒ 由 env 播种出合成组 +// (= GroupFromEnv,既有行为不变)。 +// - **键级**:面板**缺**该键 ⇒ 用 env 值补上(来源记 env);面板**已有**该键 +// (含显式空值 / 显式 false / 显式 null)⇒ **面板优先,绝不覆盖**。 +// +// 由此保证绝不出现「面板改一次、重启一次被 `.env` 打回去」。补齐写 Info 日志 +// (补了哪些键、来源 env),因面板优先而跳过的键写 Debug 日志;**任何日志都不打印 +// apiKey 等凭据**,只打组 id 与键名。 + +// envLayerSource 是补齐动作的来源标签,用于日志与断言(不泄凭据)。 +const envLayerSource = "env" + +// fillSpec 把一个 JSON 键绑到 Group 上的读/写访问器,使补齐逻辑与具体字段解耦: +// 换一族配置(channels.json 等)只需再给一张这样的表,规则、幂等与日志原样复用。 +type fillSpec struct { + // key 是 models.json 里的 JSON 键名(与结构体 tag 逐字一致)。 + key string + get func(Group) string + set func(*Group, string) +} + +// groupFillSpecs 是 `.env` 默认层覆盖的键全集,顺序即日志顺序。 +// +// 为什么是这四个键:`.env` 侧确实有对应变量(LLM_PROVIDER / LLM_BASE_URL / +// LLM_API_KEY / LLM_MODEL 及其 ANTHROPIC_* 对偶),也就是 GroupFromEnv 从 `.env` +// LLM 块写进合成组的那几个字段。`id` / `maxTokens` / `personaModels` / +// `personaProviders` 在 `.env` 里没有出处,因此永远不由 env 补。 +// +// `fallback` / `multimodal` / `reasoning.kind` 刻意**不在表内**:它们带 +// omitempty / omitzero,在 models.json 里「面板清空该键」与「从未写过该键」是同一个 +// 字节形态(键直接不写)。把它们纳入补齐,「面板里清掉 → 重启被 `.env` 静默补回」 +// 就会成立,正是本裁决明令禁止的那件事。它们仍走既有口径:面板配置缺失/无效时由 +// GroupFromEnv 提供,配置存在时以面板为准。 +var groupFillSpecs = []fillSpec{ + {key: "provider", get: func(g Group) string { return g.Provider }, set: func(g *Group, v string) { g.Provider = v }}, + {key: "baseUrl", get: func(g Group) string { return g.BaseURL }, set: func(g *Group, v string) { g.BaseURL = v }}, + {key: "apiKey", get: func(g Group) string { return g.APIKey }, set: func(g *Group, v string) { g.APIKey = v }}, + {key: "model", get: func(g Group) string { return g.Model }, set: func(g *Group, v string) { g.Model = v }}, +} + +// Fill 是一条补齐或跳过的记录:来源恒为 env 的那个键,落在哪个组上。 +type Fill struct { + GroupID string + Key string +} + +// String 便于日志与断言:形如 "openai.baseUrl"(键名不含值,不泄凭据)。 +func (f Fill) String() string { return f.GroupID + "." + f.Key } + +// Result 是一次 Materialize 的可诊断产物。Warn 是非致命异常;返回的 error 只在 +// 「该写却没写成功」时非 nil——启动流程不该因为配置文件写一次失败而失败,模型栈的 +// 既有兜底(LoadModels 回退 env)仍然成立。 +type Result struct { + // Seeded 为 true 表示面板配置缺失/为空,本次由 env 播种出合成组。 + Seeded bool + // Written 为 true 表示本次真的改动了面板文件;无改动时一个字节都不动(幂等)。 + Written bool + // Groups 是本次之后应生效的组:写入成功即文件内容,否则为 LoadModels 的兜底结论。 + Groups []Group + // Filled 是本次按 env 补上的键。 + Filled []Fill + // PanelWins 是「面板已有该键、env 也有值,但按面板优先跳过」的键。 + PanelWins []Fill + // Warn 是非致命异常:文件不可读 / 无法解析 / 补齐后仍不合法(这几种都不落盘)。 + Warn error +} + +// envLayer 是 `.env` 默认层的投影:按 provider 族分桶的「JSON 键 → env 值」。 +// 投影以 GroupFromEnv 为**唯一来源**,因此 env 层不可能发明一个 GroupFromEnv 不会 +// 写出的键;空值不入表(env 未配该键 = 没有默认值可补)。 +type envLayer struct { + byProvider map[string]map[string]string + fallback string +} + +// newEnvLayer 分别按两种协议各合成一次组,得到两族默认值。`.env` 的 LLM_PROVIDER +// 只决定「面板配置缺失时用哪族播种」,**不**决定「补齐时用哪族」——补齐按**面板组 +// 自己的 provider** 选族,因此一个 `.env` 同时配了扁平块与 ANTHROPIC_* 时,面板的 +// anthropic 组拿 anthropic 的值、openai 组拿扁平块的值。 +func newEnvLayer(env config.LLMConfig) envLayer { + byProvider := make(map[string]map[string]string, len(ProviderNames())) + for _, provider := range ProviderNames() { + probe := env + probe.Provider = provider + synthesized := GroupFromEnv(probe)[0] + values := make(map[string]string, len(groupFillSpecs)) + for _, spec := range groupFillSpecs { + if v := spec.get(synthesized); v != "" { + values[spec.key] = v + } + } + byProvider[provider] = values + } + fallback := strings.TrimSpace(env.Provider) + if fallback == "" { + fallback = ProviderOpenAI + } + return envLayer{byProvider: byProvider, fallback: fallback} +} + +// defaultsFor 返回服务该组的 env 默认值族:按组自己声明的 provider 选族;组没声明 +// (或声明了未知协议)时退回 env.Provider 那一族,让缺失的 provider 键正好由该族 +// 的 provider 值补上。 +func (l envLayer) defaultsFor(g Group) map[string]string { + if values := l.byProvider[g.Provider]; values != nil { + return values + } + return l.byProvider[l.fallback] +} + +// panelJSON 是面板配置的两种视图:类型化解码后的组(取值)与每组的原始键集合 +// (存在性)。二者缺一不可——「面板显式清空某键」与「面板从未写过该键」在类型化 +// 解码后都是零值,只有原始键集合能区分,而本裁决要求前者绝不被 env 覆盖。 +type panelJSON struct { + groups []Group + present []map[string]json.RawMessage +} + +// Materialize 跑一次「`.env` 默认层 → models.json 补齐」,返回本次的诊断产物。 +// +// 调用点只应在**进程启动**处(模型栈构建之前):面板保存走的是整组写入 + 热重载, +// 若在热重载路径上再跑补齐,面板刚显式清空的键会被立刻补回。log 可为 nil +// (slog 对 nil logger 安全,logger.WithSource(nil, …) 返回 nil)。 +func Materialize(path string, env config.LLMConfig, log *slog.Logger) (Result, error) { + log = logger.WithSource(log, "config/runtimeconfig") + raw, readErr := os.ReadFile(path) + if readErr != nil { + if os.IsNotExist(readErr) { + return materializeSeed(log, path, env, "面板配置不存在") + } + log.Warn("models.json 不可读,本次跳过补齐(模型栈按现状回退 .env)", "path", path, "error", readErr) + return Result{Groups: GroupFromEnv(env), Warn: fmt.Errorf("runtimeconfig: 读取 %s: %w", path, readErr)}, nil + } + if len(strings.TrimSpace(string(raw))) == 0 { + return materializeSeed(log, path, env, "面板配置为空文件") + } + panel, err := decodePanel(raw) + if err != nil { + // 解析失败**不覆盖**原文件:损坏的配置可以人工救回,用 env 盖掉它会连人写的 + // 组一起丢失。此处结论与 LoadModels 一致(回退 env),只是不落盘。 + log.Warn("models.json 无法解析,本次跳过补齐且不覆盖原文件(模型栈将回退 .env)", + "path", path, "error", err) + return Result{Groups: GroupFromEnv(env), Warn: fmt.Errorf("runtimeconfig: %s: %w", path, err)}, nil + } + if len(panel.groups) == 0 { + return materializeSeed(log, path, env, "面板配置为空数组") + } + return materializeFill(log, path, env, panel) +} + +// materializeSeed 处理「面板配置缺失/为空」:由 env 播种出合成组(= GroupFromEnv 的 +// 既有行为),并在合成组本身合法时落盘,让面板从此有一份一等来源。合成组不合法 +// (`.env` 自己也没配全)时不写盘,只记 Warn——运行期行为与之前一致。 +func materializeSeed(log *slog.Logger, path string, env config.LLMConfig, reason string) (Result, error) { + groups := GroupFromEnv(env) + res := Result{Seeded: true, Groups: groups} + log.Info("models.json 缺失/为空,由 .env 播种合成组", "path", path, "reason", reason, + "source", envLayerSource, "groups", len(groups)) + if err := validateAll(groups); err != nil { + res.Warn = fmt.Errorf("runtimeconfig: .env 合成的组不合法: %w", err) + log.Warn(".env 合成的组不完整,本次不落盘(模型栈仍按 env 合成组运行)", "path", path, "error", err) + return res, nil + } + if err := SaveModels(path, groups); err != nil { + return res, fmt.Errorf("runtimeconfig: 播种 %s: %w", path, err) + } + res.Written = true + log.Info("models.json 已按 .env 播种落盘", "path", path, "reason", reason, "source", envLayerSource) + return res, nil +} + +// materializeFill 处理「面板配置非空」:组集合**原样保留**(顺序也不动),只按键补齐 +// 缺失键。没有任何键需要补时一个字节都不动(幂等);补齐后仍不合法的配置同样不落盘。 +func materializeFill(log *slog.Logger, path string, env config.LLMConfig, panel panelJSON) (Result, error) { + layer := newEnvLayer(env) + groups := make([]Group, len(panel.groups)) + res := Result{} + for i, g := range panel.groups { + groups[i] = g + filled, wins := fillGroup(&groups[i], panel.present[i], layer) + res.Filled = append(res.Filled, tagKeys(g.ID, filled)...) + res.PanelWins = append(res.PanelWins, tagKeys(g.ID, wins)...) + } + res.Groups = groups + logFill(log, path, res) + if len(res.Filled) == 0 { + return res, nil + } + if err := validateAll(groups); err != nil { + res.Warn = fmt.Errorf("runtimeconfig: %s: %w", path, err) + res.Groups = GroupFromEnv(env) + log.Warn("models.json 补齐后仍不合法,本次不落盘(模型栈将回退 .env)", "path", path, "error", err) + return res, nil + } + if err := SaveModels(path, groups); err != nil { + return res, fmt.Errorf("runtimeconfig: 补齐 %s: %w", path, err) + } + res.Written = true + return res, nil +} + +// fillGroup 按键补齐一个组:面板缺该键且 env 该族有非空值 ⇒ 补上;面板已有该键 +// (含显式空值/显式 false/显式 null)⇒ 原样保留。返回补齐的键与面板优先跳过的键。 +func fillGroup(g *Group, present map[string]json.RawMessage, layer envLayer) ([]string, []string) { + defaults := layer.defaultsFor(*g) + var filled, wins []string + for _, spec := range groupFillSpecs { + value := defaults[spec.key] + if value == "" { + continue + } + if _, has := present[spec.key]; has { + wins = append(wins, spec.key) + continue + } + spec.set(g, value) + filled = append(filled, spec.key) + } + return filled, wins +} + +// decodePanel 把面板配置解码成「取值 + 键存在性」两份视图。顶层不是数组、元素不是 +// 对象、或字段类型对不上时返回错误(调用方据此跳过补齐,不覆盖原文件)。 +func decodePanel(raw []byte) (panelJSON, error) { + var present []map[string]json.RawMessage + if err := json.Unmarshal(raw, &present); err != nil { + return panelJSON{}, fmt.Errorf("decode keys: %w", err) + } + var groups []Group + if err := json.Unmarshal(raw, &groups); err != nil { + return panelJSON{}, fmt.Errorf("decode groups: %w", err) + } + if len(groups) != len(present) { + return panelJSON{}, fmt.Errorf("decode mismatch: %d 组 vs %d 组键集合", len(groups), len(present)) + } + return panelJSON{groups: groups, present: present}, nil +} + +// validateAll 逐个校验组,把出错的组下标与 id 带进错误信息。SaveModels 也会校验, +// 但这里要先校验,好把「补齐后仍不合法」与「写盘失败」分成两种处置。 +func validateAll(groups []Group) error { + for i, g := range groups { + if err := g.Validate(); err != nil { + return fmt.Errorf("%w: group %d (id=%q): %v", ErrInvalidGroup, i, g.ID, err) + } + } + return nil +} + +// tagKeys 给键名附上所属组 id,便于日志与断言定位。组 id 可能为空(面板新建未填), +// 此时用占位符,避免出现前导点让人误读。 +func tagKeys(groupID string, keys []string) []Fill { + if len(keys) == 0 { + return nil + } + if strings.TrimSpace(groupID) == "" { + groupID = "(no-id)" + } + out := make([]Fill, 0, len(keys)) + for _, key := range keys { + out = append(out, Fill{GroupID: groupID, Key: key}) + } + return out +} + +// logFill 汇报一次补齐:Info 给「补了哪些键、来源 env」,Debug 给「因面板优先而跳过 +// 的键」。只打组 id 与键名,**绝不打组里的 apiKey 等凭据**。 +func logFill(log *slog.Logger, path string, res Result) { + if len(res.Filled) == 0 { + log.Debug("models.json 无需补齐(面板配置已自足)", "path", path, "source", envLayerSource) + } else { + log.Info("models.json 已按 .env 默认层补齐缺失键", "path", path, "source", envLayerSource, + "filled", renderFills(res.Filled), "count", len(res.Filled)) + } + if len(res.PanelWins) > 0 { + log.Debug("models.json 面板优先:以下键已有面板值,未被 .env 覆盖", + "path", path, "keys", renderFills(res.PanelWins), "count", len(res.PanelWins)) + } +} + +// renderFills 把补齐记录渲染成 "组 id.键名" 的字符串切片(不含任何值)。 +func renderFills(fills []Fill) []string { + out := make([]string, 0, len(fills)) + for _, f := range fills { + out = append(out, f.String()) + } + return out +} diff --git a/internal/config/runtimeconfig/materialize_panel_test.go b/internal/config/runtimeconfig/materialize_panel_test.go new file mode 100644 index 0000000..adb9ef8 --- /dev/null +++ b/internal/config/runtimeconfig/materialize_panel_test.go @@ -0,0 +1,277 @@ +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) + } +} diff --git a/internal/config/runtimeconfig/materialize_test.go b/internal/config/runtimeconfig/materialize_test.go new file mode 100644 index 0000000..95ae5f7 --- /dev/null +++ b/internal/config/runtimeconfig/materialize_test.go @@ -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("第二次改动了文件字节") + } + }) +}