feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 窗口声明: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)。
This commit is contained in:
AskaEth committed 2026-10-04 17:57:08 +08:00
1 parent 5b2b385524
commit 72b95df22b
39 files changed
+2496 -377

No files matched your search

@@ -0,0 +1,109 @@
package runtimeconfig
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件钉住组级 `contextWindow`(design/04 附录 A.3 第 1 条:声明式窗口)。
// 窗口是上下文预算推导的输入,因此它必须:能校验、能随 env 播种、能按既有
// materialize 语义(面板优先、只补缺失键)落进面板配置。
// TestGroupValidate_RejectsNegativeContextWindow 钉住负数窗口在校验阶段报错:
// 窗口没有负数语义,静默当成「未声明」会让一个配错的部署悄悄用回落窗口。
func TestGroupValidate_RejectsNegativeContextWindow(t *testing.T) {
t.Parallel()
base := Group{ID: "g", Provider: ProviderOpenAI, BaseURL: "https://x/v1", Model: "m"}
if err := base.Validate(); err != nil {
t.Fatalf("未声明窗口(0)应当合法: %v", err)
}
declared := base
declared.ContextWindow = 1048576
if err := declared.Validate(); err != nil {
t.Fatalf("正数窗口应当合法: %v", err)
}
negative := base
negative.ContextWindow = -1
err := negative.Validate()
if err == nil {
t.Fatal("负数窗口必须报错")
}
if !strings.Contains(err.Error(), "contextWindow") {
t.Fatalf("错误信息没点出字段名: %v", err)
}
}
// TestGroupFromEnv_CarriesContextWindow 钉住 .env 合成的组带上 LLM_CONTEXT_WINDOW,
// 否则「本仓没有 provider 侧窗口发现代码」的部署永远只能吃回落窗口。
func TestGroupFromEnv_CarriesContextWindow(t *testing.T) {
t.Parallel()
for _, provider := range []string{ProviderOpenAI, ProviderAnthropic} {
env := config.LLMConfig{Provider: provider, ContextWindow: 1048576}
got := GroupFromEnv(env)[0]
if got.ContextWindow != 1048576 {
t.Fatalf("provider=%s: ContextWindow = %d, want 1048576", provider, got.ContextWindow)
}
}
}
// TestMaterializeFillsContextWindowFromEnv 钉住 per-group 播种:面板缺该键 ⇒ 由
// LLM_CONTEXT_WINDOW 补上并落盘;面板已有该键 ⇒ 面板优先(与其余键同一规则)。
func TestMaterializeFillsContextWindowFromEnv(t *testing.T) {
t.Parallel()
env := materializeEnv()
env.ContextWindow = 1048576
// 缺失 ⇒ 补齐。
path := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model"}
]`)
res, err := Materialize(path, env, discardLog())
if err != nil {
t.Fatalf("Materialize: %v", err)
}
if got, want := fillStrings(res.Filled), []string{"openai.contextWindow"}; !equalStrings(got, want) {
t.Fatalf("Filled = %v, want %v", got, want)
}
if res.Groups[0].ContextWindow != 1048576 {
t.Fatalf("补齐后的窗口 = %d, want 1048576", res.Groups[0].ContextWindow)
}
// 面板已有 ⇒ 不覆盖,且一个字节不动。
declared := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model","contextWindow":32768}
]`)
before := readRaw(t, declared)
res2, err := Materialize(declared, env, discardLog())
if err != nil {
t.Fatalf("Materialize: %v", err)
}
if res2.Groups[0].ContextWindow != 32768 {
t.Fatalf("面板声明的窗口被 env 覆盖:%d", res2.Groups[0].ContextWindow)
}
if res2.Written || readRaw(t, declared) != before {
t.Fatal("面板已有该键时不得写文件")
}
}
// TestMaterializeNeverFillsZeroContextWindow 钉住「0 不是可补的默认值」:env 未声明
// 窗口(0)时绝不写一个 0 进面板配置 —— 键的缺席才是「未声明」的唯一形态
// (omitempty),写进去只会制造 schema 噪声。
func TestMaterializeNeverFillsZeroContextWindow(t *testing.T) {
t.Parallel()
env := materializeEnv() // ContextWindow 零值
path := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model"}
]`)
res, err := Materialize(path, env, discardLog())
if err != nil {
t.Fatalf("Materialize: %v", err)
}
if len(res.Filled) != 0 {
t.Fatalf("Filled = %v, want 空(env 没有声明窗口)", fillStrings(res.Filled))
}
if strings.Contains(readRaw(t, path), "contextWindow") {
t.Fatal("env 未声明窗口却把该键写进了面板配置")
}
}
+29 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -40,11 +41,17 @@ type fillSpec struct {
// groupFillSpecs 是 `.env` 默认层覆盖的键全集,顺序即日志顺序。
//
// 为什么是这四个键:`.env` 侧确实有对应变量(LLM_PROVIDER / LLM_BASE_URL /
// 为什么是这几个键:`.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` 静默补回」
@@ -55,6 +62,27 @@ var groupFillSpecs = []fillSpec{
{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 的那个键,落在哪个组上。
+29 -16
View File
@@ -103,6 +103,14 @@ type Group struct {
// last-resort fallback step then falls back to LLM_FALLBACK_MAX_TOKENS, and
// beyond that the adapter default applies.
MaxTokens int `json:"maxTokens,omitempty"`
// ContextWindow 声明本组的模型上下文窗口(token)。它是**声明式**的窗口来源
// (design/04 附录 A.3 第 1 条):本仓没有任何 provider 侧窗口发现代码,而
// 上下文溢出压缩预算要按窗口推导(config.DeriveContextBudget),因此窗口必须
// 由人来声明。0(默认,键被省略)= 未声明,推导回落到
// config.DefaultModelContextWindow 并记一条 WARN;负数在校验阶段报错。
//
// 它是 `.env` 侧 LLM_CONTEXT_WINDOW 的对偶(per-group 播种,见 materialize.go)。
ContextWindow int `json:"contextWindow,omitempty"`
// PersonaModels binds an individual persona to its own model name within this
// group (裁决 03.4.1: the "role" of per-role routing is the persona a turn
// answers as, not the LLM message role). The carrier is deliberately
@@ -150,6 +158,9 @@ func (g Group) Validate() error {
if g.MaxTokens < 0 {
return fmt.Errorf("maxTokens must be >= 0, got %d", g.MaxTokens)
}
if g.ContextWindow < 0 {
return fmt.Errorf("contextWindow must be >= 0 (0 = 未声明,按回落窗口推导), got %d", g.ContextWindow)
}
if err := validatePersonaMap("personaModels", g.PersonaModels, false); err != nil {
return err
}
@@ -318,25 +329,27 @@ func loadFile(path string) ([]Group, error) {
func GroupFromEnv(env config.LLMConfig) []Group {
if env.Provider == ProviderAnthropic {
return []Group{{
ID: ProviderAnthropic,
Provider: ProviderAnthropic,
BaseURL: env.AnthropicBaseURL,
APIKey: env.AnthropicAPIKey,
Model: env.AnthropicModel,
Fallback: env.AnthropicFallback,
Multimodal: env.Multimodal,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
ID: ProviderAnthropic,
Provider: ProviderAnthropic,
BaseURL: env.AnthropicBaseURL,
APIKey: env.AnthropicAPIKey,
Model: env.AnthropicModel,
Fallback: env.AnthropicFallback,
Multimodal: env.Multimodal,
ContextWindow: env.ContextWindow,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}
return []Group{{
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Multimodal: env.Multimodal,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Multimodal: env.Multimodal,
ContextWindow: env.ContextWindow,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}