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 一条
This commit is contained in:
1 parent
1728addcfe
commit
910d154d35
6 files changed
+1016
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("第二次改动了文件字节")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user