Files
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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)。
2026-10-04 17:57:08 +08:00

336 lines
15 KiB
Go
Raw Permalink 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"
"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
}