Files
Cyrene_v2/internal/config/runtimeconfig/materialize.go
T
AskaEth 910d154d35
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,cyrene): .env → 面板配置的启动期 materialize(主人裁决:面板是一等来源,.env 退回入口/播种)
- 主人原话:「.env 只是基础入口,生产环境下应该在每次启动进程时从 .env 把变量(包括后续更新过的)
  写入面板专用配置文件,而不是仅依赖 .env 这个脆弱的文件。」⇒ 根治「每加一种能力就再加一个 env 键」
- 规则(主会话裁定,已写入 02 回执):
  · **组集合**:面板配置非空 ⇒ 完全由面板决定(env 不新增/不复活被删组/不重排);缺失/空/[]/null ⇒ 由 .env 播种(=今天 env-only 行为)
  · **键级**:面板缺 provider/baseUrl/apiKey/model 某键 ⇒ 用 .env 补;**面板已有该键(含显式 ""/null/false)⇒ 面板优先,绝不覆盖**
  · 判定用原始 JSON 的**键存在性**(故显式空值也算声明);`len(Filled)==0` ⇒ 一个字节都不动(幂等)
  · 面板不可读/解析失败/补齐后不合法 ⇒ **不覆盖原文件**(保人工可救),只 WARN;写盘失败不阻断启动
  · 带 omitempty/omitzero 的 fallback/multimodal/reasoning.kind **不在补齐表内**(面板清空与未声明字节相同,
    补回会撤销面板操作);它们仍以面板为准、文件缺失/无效时由 .env 兜底
- 落点:`internal/config/runtimeconfig/materialize.go`(导出 Materialize + 诊断 Result/Fill)+ 组合根
  `cmd/cyrene/models_materialize.go`,在 `NewRuntimeLLM` **之前**跑一次(**不在热重载路径上**,否则面板刚清空的键会被立刻补回)
- 复核:build/vet 绿 + 全树 `go test ./... -count=1` 全绿 + linux/darwin 交叉编译绿;6 条真断言
  (面板值胜出且字节不变 / 缺键补齐并落盘 / 被删组不复活 / 五态播种 / 幂等逐字节相同 /
  multimodal 回归两路径);**4 条变异反证**(一律 env 覆盖、只首启播种、组集合由 env 决定、无可补也重写)均已逐字节还原;
  另加端到端:`.env`→启动补齐→真 NewRuntimeLLM→真面板写路径改成 panel-model→再启动 ⇒ 仍是面板值
- 契约面:REST/WS/admin 零变更(只改本地面板配置文件 models.json 的语义);待落 Docs v0.59 一条
2026-10-04 16:39:33 +08:00

308 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtimeconfig
import (
"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
}