- 主人原话:「.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 一条
84 lines
3.4 KiB
Go
84 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|