Files
Cyrene_v2/internal/config/runtimeconfig/context_window_test.go
T
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

110 lines
4.2 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 (
"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 未声明窗口却把该键写进了面板配置")
}
}