package runtimeconfig import ( "encoding/json" "fmt" "log/slog" "os" "strconv" "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 补。 // // `contextWindow` 是唯一被纳入的**整型**键(LLM_CONTEXT_WINDOW 的 per-group 播种, // design/04 附录 A.3 第 1 条)。它用十进制字符串承载,但 0 不构成一个可补的默认值 // —— models.json 里 0 与「键被省略」是同一个字节形态(omitempty), // 因此 env 只补正数窗口,绝不让「面板里清掉窗口 → 重启被 env 静默补回」成立 // (与下面 fallback/multimodal/reasoning.kind 被排除的理由同源)。 // // `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 }}, {key: "contextWindow", get: func(g Group) string { return positiveIntText(g.ContextWindow) }, set: func(g *Group, v string) { g.ContextWindow = parsePositiveInt(v) }}, } // positiveIntText 把一个「0 = 未声明」的整型渲染成可补的字符串:0 不算默认值 // (见 groupFillSpecs 的说明),因此返回空串表示「env 层没有可补的值」。 func positiveIntText(v int) string { if v <= 0 { return "" } return strconv.Itoa(v) } // parsePositiveInt 解析 env 层给出的整数;解析不出来(理论上不会发生:值来自 // GroupFromEnv 的规范化十进制)时按未声明处理,绝不写进一个瞎猜的数。 func parsePositiveInt(raw string) int { n, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || n <= 0 { return 0 } return n } // 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 }