feat(config,cyrene): .env → 面板配置的启动期 materialize(主人裁决:面板是一等来源,.env 退回入口/播种)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 主人原话:「.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:
AskaEth committed 2026-10-04 16:39:33 +08:00
1 parent 1728addcfe
commit 910d154d35
6 files changed
+1016 -1

No files matched your search

@@ -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
}