- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
336 lines
15 KiB
Go
336 lines
15 KiB
Go
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
|
||
}
|