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

+7 -88
View File
@@ -132,94 +132,6 @@ type DBConfig struct {
PGURL string `env:"DB_PG_URL"`
}
// LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
// wire protocol; every provider has its own independent base/key/model/fallback
// so multiple vendors can coexist.
type LLMConfig struct {
// Provider declares which protocol to use: "openai" (OpenAI-compatible
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
Provider string `env:"LLM_PROVIDER" default:"openai"`
// OpenAI-compatible provider (default). Kept flat for backward compatibility
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
BaseURL string `env:"LLM_BASE_URL"`
APIKey string `env:"LLM_API_KEY"`
Model string `env:"LLM_MODEL"`
Fallback string `env:"LLM_FALLBACK_MODEL"`
// VisionModel is the dedicated model behind the vision tool (image
// understanding). It is the tool's model, not the main model's ability: the
// per-persona/per-request routing decision belongs to the vision Router, and
// the main model's own image input capability is declared by the active
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
VisionModel string `env:"LLM_VISION_MODEL"`
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
// 的「独立视觉模型 + OCR 模型」)。
OCRModel string `env:"LLM_OCR_MODEL"`
// Multimodal 声明**主模型自身**能否收图:models.json 组 `multimodal` 的 env
// 等价物,只作用于 .env 合成的组,默认 false(语义与分工见 GroupFromEnv)。
Multimodal bool `env:"LLM_MULTIMODAL" default:"false"`
// Local is the optional third degradation level (裁决 03.4.3): primary →
// fallback → local. It defaults to off because the current machine cannot run
// a local model comfortably; when off the chain keeps its two-level shape.
Local LocalLLMConfig
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
// (primary → fallback). It is the final safety budget: it only applies when
// the chain actually has a fallback step, that group sets no per-group
// MaxTokens (models.json maxTokens) and the request carries no explicit
// budget. Zero disables the cap (the adapter default then applies).
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
// the vendor from the model name/base URL and sends nothing when the shape is
// ambiguous (the conservative default, so an existing .env is unchanged);
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
// explicitly. It is the env-fallback twin of the models.json group's
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
// Anthropic native provider, used when Provider=anthropic. Independent
// config block so each vendor is a self-contained config.
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
AnthropicModel string `env:"ANTHROPIC_MODEL"`
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
}
// LocalLLMConfig configures the optional local model rung — the third level of
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
// and defaults to off: a machine that cannot run a local model comfortably must
// not silently pay for one. It becomes an active chain step only when Enabled is
// true and both BaseURL and Model are set; anything less is reported and skipped
// rather than producing a half-configured step.
type LocalLLMConfig struct {
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
// APIKey is the local endpoint's key; empty is normal for a local server.
APIKey string `env:"LLM_LOCAL_API_KEY"`
// Model is the local model name. Without it the rung stays off.
Model string `env:"LLM_LOCAL_MODEL"`
}
// Configured reports whether the local rung has enough configuration to act as a
// chain step. Enabled alone is not enough: a missing endpoint or model must not
// become a step that can only fail.
func (l LocalLLMConfig) Configured() bool {
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
}
// EmbeddingConfig holds the optional semantic embedding API settings. When
// unset, the runtime falls back to built-in n-gram/FTS vectors.
type EmbeddingConfig struct {
@@ -466,6 +378,13 @@ func Load(envPath string) (*Config, error) {
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
return nil, err
}
// 「未设置」与「显式 0」必须分开(见 ContextConfig.MaxTokensSet):反射解码只能
// 给出值本身,因此这里补上「这个键到底有没有被配过」。
cfg.Context.MaxTokensSet = envProvided(ContextMaxTokensEnv, fromEnv)
if cfg.LLM.ContextWindow < 0 {
return nil, fmt.Errorf("config: LLM_CONTEXT_WINDOW 不能为负(%d):0 = 未声明(按回落窗口推导)",
cfg.LLM.ContextWindow)
}
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
+45 -15
View File
@@ -1,23 +1,53 @@
package config
// DefaultContextMaxTokens 是 CONTEXT_MAX_TOKENS 的默认值(32Ki token),与
// loop.DefaultMaxContextTokens 取同一个数:项目早已把「上下文溢出压缩预算」
// 定为 32K,默认值直接复用它,而不是另起一个数。两处的一致性由
// cmd/cyrene/wire_context_test.go 锁定。
const DefaultContextMaxTokens = 32768
// 本文件承载「上下文预算」的配置语义(design/04 §1.3 上下文溢出压缩 +
// 附录 A.3 的主人裁决)。推导本身在 context_budget.go。
//
// CONTEXT_MAX_TOKENS 是**三态**取值:
//
// - 未设置(默认) = 按模型窗口推导(DeriveContextBudget);
// - > 0 = 显式覆盖(原样使用);
// - 0 / 负数 = 关闭压缩(行为与引入推导之前逐字节一致)。
//
// 「未设置」不能用 0 表达:0 已经被「关闭压缩」占用。因此「有没有配过这个键」
// 必须单独记录(ContextConfig.MaxTokensSet,由 Load 在解码阶段填),
// 这正是 design/04 附录 A.3 第 2 条要求的「用 os.LookupEnv 判未设置,不要靠 0」。
//
// 旧口径(「默认模型 gpt-4o 窗口 128K ÷ 4 ⇒ 32K」)随本切片作废:默认模型的
// 真实窗口已达 1M token(DevDocs/reference/deepseek/facts-for-cyrene.md),
// 固定 32K 会既丢上下文、又因反复压缩作废 KV 缓存前缀而反复烧钱(附录 A.2)。
// ContextConfig 承载运行时循环的上下文预算(design/04 §1.3 上下文溢出压缩)。
// ContextMaxTokensEnv 是上下文预算的配置键名。它必须与 ContextConfig.MaxTokens
// 的 env 标签**逐字一致**(Load 用它判定「未设置」),一致性由
// context_budget_test.go 的 TestContextMaxTokensEnvMatchesTag 钉住。
const ContextMaxTokensEnv = "CONTEXT_MAX_TOKENS"
// ContextConfig 承载运行时循环的上下文预算(design/04 §1.3)。
// 它与 MCP / Capacity 一样是运行时调优量,由组合根消费。
type ContextConfig struct {
// MaxTokens 是触发溢出压缩的上下文预算,单位是循环离线估算的 token 数
// (CONTEXT_MAX_TOKENS)。**0 或负值 = 关闭压缩**,行为与本条接线前完全
// 一致;未配置时用 DefaultContextMaxTokens(即默认启用)。
// (CONTEXT_MAX_TOKENS)。三态语义见本文件顶部说明。
//
// 取值依据:默认模型(.env.example 的 gpt-4o)上下文窗口为 128K token,
// 32K 只有它的四分之一 —— 既给系统提示词 / 工具 schema / 本轮输出留出
// 充足余量(不会把窗口塞到上游静默截断或报错),又比日常对话的实际体量
// 大一个量级(正常聊天几乎不触发压缩,宁可不压也不频繁压)。真触发时
// compactor 仍保留 50% 预算的近期尾部(含尾部工具结果)并只把更早的轮次
// 摘要成一条前导消息,因此不会打断正在进行的对话(design/04 §1.3)。
MaxTokens int `env:"CONTEXT_MAX_TOKENS" default:"32768"`
// **它刻意没有 default 标签**:未配置时值为 0,而「0 且未配置」与
// 「显式 0」由 MaxTokensSet 区分 —— 前者按窗口推导,后者关闭压缩。
MaxTokens int `env:"CONTEXT_MAX_TOKENS"`
// MaxTokensSet 报告 CONTEXT_MAX_TOKENS 是否由进程环境或 .env 文件给出。
// 它由 Load 填写,不参与反射解码(没有 env 标签)。
//
// 判定规则(DeriveContextBudget 是唯一判据):MaxTokens > 0 一律按显式覆盖、
// < 0 一律按关闭;只有 MaxTokens == 0 需要本标志区分「显式 0 = 关闭」与
// 「未设置 = 推导」。因此手工构造 Config 的调用方给出非零值时不必同步本标志,
// 但**要表达「显式 0 = 关闭」就必须置真**。
MaxTokensSet bool
// DocumentMaxInjectChars 是**单份附件正文注入模型可见上下文**的字符上限
// (DOCUMENT_MAX_INJECT_CHARS)。0(默认)= 按推导出的上下文预算算
// (loop.DocumentInjectCharsFor);> 0 = 显式覆盖。
//
// 它与「sidecar 存多少」是两个数(design/24 §9.6 连带项、切片 T1 §7.2):
// sidecar 存抽取得到的全文,只受安全上限 documents.MaxSidecarBytes 约束;
// 本项只决定每回合往上下文里注入多少,截断因此发生在**装配期**,
// 于是清单行的续读游标才真是「继续读的起点」。
DocumentMaxInjectChars int `env:"DOCUMENT_MAX_INJECT_CHARS"`
}
+220
View File
@@ -0,0 +1,220 @@
package config
import (
"fmt"
"strconv"
"strings"
)
// 本文件是上下文预算的**推导**(design/04 附录 A.3 的主人裁决):
//
// budget = min(窗口 × 比例, 窗口 − 输出保留)
//
// 它是一条纯函数,输入全部显式给出(窗口、该组 maxTokens、显式覆盖、比例),
// 因此组合根只需把配置与生效模型组摊平喂进来,推导逻辑本身可表驱动测试。
const (
// DefaultModelContextWindow 是模型窗口**未声明**时的回落窗口(128 Ki token,
// design/04 附录 A.3 第 3 条)。回落而不是报错,是为了让没声明窗口的部署照常
// 工作;但组合根会为它记**恰好一条** WARN,提示「声明 contextWindow /
// LLM_CONTEXT_WINDOW 才能吃满窗口」—— 默认模型的真实窗口已达 1M token,
// 回落值只是保守兜底。
DefaultModelContextWindow = 131072
// DefaultContextBudgetRatio 是预算占窗口的默认比例 2/3。主人 2026-10-04 裁决
// 「1/4 太小,1/2 到 3/4 之间吧」,本切片取区间内偏大的一侧:工具 schema、
// 多轮工具结果与思考输出(缺省 64K、上限 384K)都要从窗口里分走一部分。
// 可配:CONTEXT_BUDGET_RATIO。
DefaultContextBudgetRatio = 2.0 / 3.0
// MinContextOutputReserve 是给**输出**留的最小 token 余量(design/04 附录 A.3
// 第 3 条):官方明说输入 + 输出总长受窗口限制,而思考模式的缺省输出就有 64K,
// 因此输入预算必须给输出让位。
MinContextOutputReserve = 32768
// ContextBudgetRatioEnv 是推导比例的配置键名。
ContextBudgetRatioEnv = "CONTEXT_BUDGET_RATIO"
)
// ContextBudgetMode 说明一个预算数是「怎么来的」。它只用于日志与断言,
// 不参与任何行为分支之外的判定。
type ContextBudgetMode string
const (
// ContextBudgetDerived 表示按窗口推导得到。
ContextBudgetDerived ContextBudgetMode = "derived"
// ContextBudgetOverride 表示 CONTEXT_MAX_TOKENS 显式覆盖。
ContextBudgetOverride ContextBudgetMode = "override"
// ContextBudgetOff 表示显式关闭压缩(0 / 负数)。
ContextBudgetOff ContextBudgetMode = "off"
)
// ContextBudgetInput 是一次推导的全部输入。字段都是值,没有任何隐式来源:
// 「这个数从哪来」由组合根一处决定(cmd/cyrene/wire_context.go)。
type ContextBudgetInput struct {
// Window 是声明的模型窗口(token)。0 = 未声明(回落到
// DefaultModelContextWindow 并提示)。
Window int
// GroupMaxTokens 是生效模型组声明的 maxTokens(models.json)。它参与
// outputReserve 的计算;0 = 该组未声明。
GroupMaxTokens int
// Override 是 CONTEXT_MAX_TOKENS 的取值。
Override int
// OverrideConfigured 报告 Override 是否被显式配置(见 ContextConfig.MaxTokensSet)。
OverrideConfigured bool
// Ratio 是推导比例。非正数或大于 1 视为非法,回落到
// DefaultContextBudgetRatio(非法值的报告由调用方负责,见
// LoadContextBudgetRatio:那里才知道原始文本,能记出「配了什么」)。
Ratio float64
}
// ContextBudget 是一次推导的结论。Tokens 是循环真正用的预算;
// 其余字段是给日志与断言的可追溯信息。
type ContextBudget struct {
// Tokens 是最终预算:> 0 表示启用压缩;<= 0 表示关闭压缩。
Tokens int
// Mode 说明这个数怎么来的。
Mode ContextBudgetMode
// Window 是本次实际采用的窗口(未声明时为回落值)。
Window int
// WindowDeclared 报告窗口是否来自声明。为 false 且 Mode == derived 时,
// 组合根记一条 WARN(提示声明窗口才能吃满)—— 这是「恰好一条」的那条。
WindowDeclared bool
// Ratio 是本次实际采用的比例(已回落)。
Ratio float64
// OutputReserve 是本次采用的输出保留量。
OutputReserve int
}
// DeriveContextBudget 按 design/04 附录 A.3 推导上下文预算。
//
// 取值优先级:显式覆盖 > 关闭 > 推导。具体地 ——
//
// - Override > 0:原样返回(override);
// - Override < 0,或 Override == 0 且 OverrideConfigured:返回该值(off,
// 0 与负数都表示关闭压缩,与接线前一致,**不推导**);
// - 其余(即没配过):按窗口推导。
//
// 推导式 budget = min(window × ratio, window − outputReserve):
// 前者给「不能把窗口塞满」留比例余量,后者给输出留绝对余量。
// 输出保留取 max(MinContextOutputReserve, 该组 maxTokens),并且**不超过窗口的
// 一半** —— 组把 maxTokens 声明得比窗口还大时(配置自相矛盾),也不能让
// 「窗口 − 输出」退化到 0 或负数,否则预算会变成一个荒唐的极小值。
func DeriveContextBudget(in ContextBudgetInput) ContextBudget {
if in.Override > 0 {
return ContextBudget{Tokens: in.Override, Mode: ContextBudgetOverride}
}
if in.Override < 0 || in.OverrideConfigured {
return ContextBudget{Tokens: in.Override, Mode: ContextBudgetOff}
}
window := in.Window
declared := window > 0
if !declared {
window = DefaultModelContextWindow
}
ratio := in.Ratio
if ratio <= 0 || ratio > 1 {
ratio = DefaultContextBudgetRatio
}
reserve := ContextOutputReserve(in.GroupMaxTokens)
if half := window / 2; reserve > half {
reserve = half
}
tokens := int(float64(window) * ratio)
if limit := window - reserve; tokens > limit {
tokens = limit
}
if tokens < 1 {
tokens = 1
}
return ContextBudget{
Tokens: tokens,
Mode: ContextBudgetDerived,
Window: window,
WindowDeclared: declared,
Ratio: ratio,
OutputReserve: reserve,
}
}
// ContextOutputReserve 返回给输出留的 token 余量:max(MinContextOutputReserve,
// groupMaxTokens)。组声明了更大的输出预算时以它为准(思考模式上限 384K)。
func ContextOutputReserve(groupMaxTokens int) int {
if groupMaxTokens > MinContextOutputReserve {
return groupMaxTokens
}
return MinContextOutputReserve
}
// LoadContextBudgetRatio 读取推导比例(CONTEXT_BUDGET_RATIO),优先级与其余配置
// 完全一致:进程环境 > .env > 默认。未配置时返回 DefaultContextBudgetRatio。
//
// 为什么需要读取函数而不是给 Config 加一个 float 字段:Config 的反射解码器只做
// 标量赋值,承载不了「非法值必须被报告并回落」的语义(记 WARN 需要原始文本);
// 而直接 os.Getenv 会让只写在 .env 里的声明被静默忽略(.env 由 parseDotEnv 读进
// 独立 map,并不写回进程环境)。这里复用同一套优先级与同一份 .env 解析,因此两种
// 写法都真实生效 —— 与 LoadOwnerIdentityInjection 同源。envPath 为空表示不看
// .env(与 Load 的约定一致)。
//
// 返回的 error 只承载「配了但读不懂」:调用方据此记一条带原始取值的 WARN 并回落
// 默认比例,绝不静默带过(AGENTS §10:非法配置不许静默失效)。
func LoadContextBudgetRatio(envPath string) (float64, error) {
fromEnv := map[string]string{}
if envPath != "" {
m, err := parseDotEnv(envPath)
if err != nil {
return DefaultContextBudgetRatio, fmt.Errorf("config: read %s: %w", envPath, err)
}
fromEnv = m
}
raw := strings.TrimSpace(lookupRaw(ContextBudgetRatioEnv, "", fromEnv))
if raw == "" {
return DefaultContextBudgetRatio, nil
}
ratio, err := ParseContextBudgetRatio(raw)
if err != nil {
return DefaultContextBudgetRatio, err
}
return ratio, nil
}
// ParseContextBudgetRatio 把比例文本解析成 (0, 1] 内的浮点数。两种写法都接受:
// 小数(`0.6667`)与分数(`2/3`)—— 设计文档与主人裁决都用分数表述,若只认小数,
// 写成 `2/3` 的部署会得到一条 WARN 并被静默回落到默认值,那正是「配了当没配」。
func ParseContextBudgetRatio(raw string) (float64, error) {
text := strings.TrimSpace(raw)
value, err := parseRatioValue(text)
if err != nil {
return DefaultContextBudgetRatio, err
}
if value <= 0 || value > 1 {
return DefaultContextBudgetRatio, fmt.Errorf("config: %s=%q 超出可取范围 (0, 1]",
ContextBudgetRatioEnv, raw)
}
return value, nil
}
// parseRatioValue 解析单个比例文本,失败时给出带原始取值的错误。
func parseRatioValue(text string) (float64, error) {
badRatio := func() error {
return fmt.Errorf("config: %s=%q 不是合法比例(用 0.6667 或 2/3 这类写法)",
ContextBudgetRatioEnv, text)
}
if num, den, ok := strings.Cut(text, "/"); ok {
n, err := strconv.ParseFloat(strings.TrimSpace(num), 64)
if err != nil {
return 0, badRatio()
}
d, err := strconv.ParseFloat(strings.TrimSpace(den), 64)
if err != nil || d == 0 {
return 0, badRatio()
}
return n / d, nil
}
n, err := strconv.ParseFloat(text, 64)
if err != nil {
return 0, badRatio()
}
return n, nil
}
+335
View File
@@ -0,0 +1,335 @@
package config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// 本文件钉住上下文预算的推导(design/04 附录 A.3):budget = min(窗口 × 比例,
// 窗口 − 输出保留),以及 CONTEXT_BUDGET_RATIO 的解析与回落口径。
// 默认模型的真实窗口(facts-for-cyrene.md)与两条便于阅读的取值。
const (
oneMillionWindow = 1048576
maxTokens384K = 384 * 1024
)
// budgetCase 是推导用例的一条断言:输入 + 期望的结论要点。
type budgetCase struct {
name string
in ContextBudgetInput
wantTokens int
wantMode ContextBudgetMode
wantWindow int
wantDeclared bool
wantReserve int
}
// TestDeriveContextBudget_Derived 钉住推导侧的五种情形(任务书 §4 必测 1 的
// ①②③,外加比例回落与输出保留封顶)。
func TestDeriveContextBudget_Derived(t *testing.T) {
t.Parallel()
ratio := DefaultContextBudgetRatio
cases := []budgetCase{
{
// ① 1M × 2/3 = 699050 token(文档里写作「683K」,那是 Ki:699050/1024 ≈ 683)。
name: "1M 窗口按 2/3 推导",
in: ContextBudgetInput{Window: oneMillionWindow, Ratio: ratio},
wantTokens: 699050,
wantMode: ContextBudgetDerived,
wantWindow: oneMillionWindow,
wantDeclared: true,
wantReserve: MinContextOutputReserve,
},
{
// ② 该组 maxTokens=384K ⇒ 输出保留顶到 384K ⇒ min(699050, 1M−384K=655360)。
name: "带 384K 输出保留时取窗口减输出的那一侧",
in: ContextBudgetInput{Window: oneMillionWindow, GroupMaxTokens: maxTokens384K, Ratio: ratio},
wantTokens: 655360,
wantMode: ContextBudgetDerived,
wantWindow: oneMillionWindow,
wantDeclared: true,
wantReserve: maxTokens384K,
},
{
// ③ 未声明窗口 ⇒ 回落 131072,131072 × 2/3 = 87381(截尾),
// 且 WindowDeclared=false(组合根据此记恰好一条 WARN)。
name: "未声明窗口回落 128Ki 并提示",
in: ContextBudgetInput{Ratio: ratio},
wantTokens: 87381,
wantMode: ContextBudgetDerived,
wantWindow: DefaultModelContextWindow,
wantDeclared: false,
wantReserve: MinContextOutputReserve,
},
{
name: "非法比例回落到 2/3",
in: ContextBudgetInput{Window: oneMillionWindow, Ratio: 3},
wantTokens: 699050,
wantMode: ContextBudgetDerived,
wantWindow: oneMillionWindow,
wantDeclared: true,
wantReserve: MinContextOutputReserve,
},
{
// maxTokens 被声明得比窗口还大(配置矛盾)时,输出保留封顶在窗口一半,
// 预算绝不退化成 0 或负数。
name: "输出保留不超过窗口一半",
in: ContextBudgetInput{Window: 32768, GroupMaxTokens: maxTokens384K, Ratio: ratio},
wantTokens: 16384,
wantMode: ContextBudgetDerived,
wantWindow: 32768,
wantDeclared: true,
wantReserve: 16384,
},
}
for _, c := range cases {
assertBudgetCase(t, c)
}
}
// TestDeriveContextBudget_OverrideAndOff 钉住显式覆盖与关闭(任务书 §4 必测 1 的
// ④⑤):两者都**不推导**,窗口声明与否都不影响结论。
func TestDeriveContextBudget_OverrideAndOff(t *testing.T) {
t.Parallel()
ratio := DefaultContextBudgetRatio
cases := []budgetCase{
{
name: "显式覆盖原样使用",
in: ContextBudgetInput{Window: oneMillionWindow, Override: 4096, OverrideConfigured: true, Ratio: ratio},
wantTokens: 4096,
wantMode: ContextBudgetOverride,
},
{
name: "非零覆盖不必同步标志位也生效",
in: ContextBudgetInput{Window: oneMillionWindow, Override: 600, Ratio: ratio},
wantTokens: 600,
wantMode: ContextBudgetOverride,
},
{
name: "显式 0 = 关闭压缩(不推导)",
in: ContextBudgetInput{Window: oneMillionWindow, Override: 0, OverrideConfigured: true, Ratio: ratio},
wantTokens: 0,
wantMode: ContextBudgetOff,
},
{
name: "负值 = 关闭压缩(不推导)",
in: ContextBudgetInput{Window: oneMillionWindow, Override: -1, OverrideConfigured: true, Ratio: ratio},
wantTokens: -1,
wantMode: ContextBudgetOff,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got := DeriveContextBudget(c.in)
if got.Tokens != c.wantTokens || got.Mode != c.wantMode {
t.Fatalf("DeriveContextBudget = {tokens:%d mode:%s}, want {tokens:%d mode:%s}",
got.Tokens, got.Mode, c.wantTokens, c.wantMode)
}
if got.WindowDeclared || got.Window != 0 || got.OutputReserve != 0 {
t.Fatalf("覆盖/关闭态不该带推导结论:%+v", got)
}
})
}
}
// assertBudgetCase 校验一条推导用例:结论要点 + 推导侧特有的窗口与保留量。
func assertBudgetCase(t *testing.T, c budgetCase) {
t.Helper()
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got := DeriveContextBudget(c.in)
if got.Tokens != c.wantTokens || got.Mode != c.wantMode {
t.Fatalf("DeriveContextBudget = {tokens:%d mode:%s}, want {tokens:%d mode:%s}",
got.Tokens, got.Mode, c.wantTokens, c.wantMode)
}
if got.WindowDeclared != c.wantDeclared {
t.Fatalf("WindowDeclared = %v, want %v", got.WindowDeclared, c.wantDeclared)
}
if got.Window != c.wantWindow || got.OutputReserve != c.wantReserve {
t.Fatalf("window/reserve = %d/%d, want %d/%d", got.Window, got.OutputReserve, c.wantWindow, c.wantReserve)
}
})
}
// TestDeriveContextBudget_OneMillionWindowBeatsTheOld32K 是主人裁决的直接体现:
// 1M 窗口下推导出的预算必须是旧默认 32K 的二十倍以上 —— 否则「既丢上下文、又反复
// 烧缓存」(design/04 附录 A.2 第 2 条)的老毛病没被治好。
func TestDeriveContextBudget_OneMillionWindowBeatsTheOld32K(t *testing.T) {
t.Parallel()
const oldDefault = 32768
got := DeriveContextBudget(ContextBudgetInput{Window: oneMillionWindow})
if got.Tokens <= oldDefault*20 {
t.Fatalf("1M 窗口推导出的预算 = %d,仍贴着旧默认 %d(推导没生效)", got.Tokens, oldDefault)
}
}
// TestParseContextBudgetRatio 钉住比例的两种写法与非法值判定(非法值由调用方记
// WARN 并回落默认,绝不静默生效)。
func TestParseContextBudgetRatio(t *testing.T) {
t.Parallel()
ok := []struct {
raw string
want float64
}{
{"0.5", 0.5},
{"0.6667", 0.6667},
{" 2/3 ", 2.0 / 3.0},
{"3/4", 0.75},
{"1", 1},
}
for _, c := range ok {
got, err := ParseContextBudgetRatio(c.raw)
if err != nil {
t.Fatalf("ParseContextBudgetRatio(%q) 报错: %v", c.raw, err)
}
if got != c.want {
t.Fatalf("ParseContextBudgetRatio(%q) = %v, want %v", c.raw, got, c.want)
}
}
for _, raw := range []string{"maybe", "0", "-0.5", "1.5", "2/0", "/3", "3/"} {
if _, err := ParseContextBudgetRatio(raw); err == nil {
t.Fatalf("ParseContextBudgetRatio(%q) 竟然通过了 —— 非法比例必须被报告,不得静默生效", raw)
}
}
}
// TestLoadContextBudgetRatio 钉住读取链:进程环境 > .env > 默认,且非法值返回
// 错误(调用方据此记 WARN 回落默认,并带上原始取值)。
func TestLoadContextBudgetRatio(t *testing.T) {
dir := t.TempDir()
envFile := filepath.Join(dir, "test.env")
if err := os.WriteFile(envFile, []byte(ContextBudgetRatioEnv+"=0.5\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
unsetenvForTest(t, ContextBudgetRatioEnv)
got, err := LoadContextBudgetRatio(envFile)
if err != nil {
t.Fatalf("读 .env 里的比例: %v", err)
}
if got != 0.5 {
t.Fatalf(".env 里的比例 = %v, want 0.5", got)
}
// 进程环境优先于 .env。
t.Setenv(ContextBudgetRatioEnv, "2/3")
got, err = LoadContextBudgetRatio(envFile)
if err != nil {
t.Fatalf("读进程环境里的比例: %v", err)
}
if got != 2.0/3.0 {
t.Fatalf("进程环境里的比例 = %v, want 2/3", got)
}
// 非法值:返回错误,并回落到默认(错误里带原始取值,便于 WARN)。
t.Setenv(ContextBudgetRatioEnv, "四分之三")
got, err = LoadContextBudgetRatio(envFile)
if err == nil {
t.Fatal("非法比例必须返回错误")
}
if got != DefaultContextBudgetRatio {
t.Fatalf("非法比例的回落 = %v, want %v", got, DefaultContextBudgetRatio)
}
// 未配置(.env 也不看):默认比例,不报错。
t.Setenv(ContextBudgetRatioEnv, "")
got, err = LoadContextBudgetRatio(filepath.Join(dir, "missing.env"))
if err != nil || got != DefaultContextBudgetRatio {
t.Fatalf("未配置比例 = (%v, %v), want (%v, nil)", got, err, DefaultContextBudgetRatio)
}
}
// unsetenvForTest 清掉一个环境变量并在测试结束时还原(t.Setenv 只能设值,而空串
// 会被解析成 0 / 空串,与「未设置」不是一回事 —— 本文件恰好要区分这两者)。
func unsetenvForTest(t *testing.T, key string) {
t.Helper()
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("Unsetenv(%s): %v", key, err)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, prev)
return
}
_ = os.Unsetenv(key)
})
}
// TestContextMaxTokensEnvMatchesTag 钉住 ContextMaxTokensEnv 与字段的 env 标签
// 逐字一致:Load 用前者判定「未设置」,标签决定反射解码读哪个变量;两者一旦漂移,
// 「未设置 ⇒ 按窗口推导」就会在真实进程里静默失效(而单测仍可能全绿)。
func TestContextMaxTokensEnvMatchesTag(t *testing.T) {
t.Parallel()
field, ok := reflect.TypeOf(ContextConfig{}).FieldByName("MaxTokens")
if !ok {
t.Fatal("ContextConfig 里没有 MaxTokens 字段")
}
if got := field.Tag.Get("env"); got != ContextMaxTokensEnv {
t.Fatalf("MaxTokens 的 env 标签 = %q, want %q", got, ContextMaxTokensEnv)
}
}
// TestLoad_ContextMaxTokensTriState 钉住三态判定的来源:只有真的配过这个键,
// MaxTokensSet 才为真 —— 反射解码只能给出值,分不出「没配」与「配了 0」。
func TestLoad_ContextMaxTokensTriState(t *testing.T) {
dir := t.TempDir()
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
unsetenvForTest(t, ContextMaxTokensEnv)
cfg, err := Load(filepath.Join(dir, "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Context.MaxTokens != 0 || cfg.Context.MaxTokensSet {
t.Fatalf("未配置时 = (%d, set=%v), want (0, false)(未设置 ⇒ 按窗口推导)",
cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
}
t.Setenv(ContextMaxTokensEnv, "0")
cfg, err = Load(filepath.Join(dir, "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Context.MaxTokens != 0 || !cfg.Context.MaxTokensSet {
t.Fatalf("显式 0 时 = (%d, set=%v), want (0, true)(显式 0 ⇒ 关闭压缩)",
cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
}
// .env 里写了也算「配过」(与进程环境同一优先级链)。
envFile := filepath.Join(dir, "test.env")
if err := os.WriteFile(envFile, []byte(ContextMaxTokensEnv+"=2048\n"), 0o600); err != nil {
t.Fatalf("写 .env: %v", err)
}
unsetenvForTest(t, ContextMaxTokensEnv)
cfg, err = Load(envFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Context.MaxTokens != 2048 || !cfg.Context.MaxTokensSet {
t.Fatalf(".env 配置时 = (%d, set=%v), want (2048, true)", cfg.Context.MaxTokens, cfg.Context.MaxTokensSet)
}
}
// TestLoad_RejectsNegativeContextWindow 钉住窗口的负数校验:窗口没有负数语义,
// 静默当成「未声明」会让一个配错的部署悄悄用回落窗口而非报错。
func TestLoad_RejectsNegativeContextWindow(t *testing.T) {
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
t.Setenv("LLM_CONTEXT_WINDOW", "-1")
if _, err := Load(filepath.Join(t.TempDir(), "absent.env")); err == nil {
t.Fatal("负数窗口必须让 Load 报错")
}
t.Setenv("LLM_CONTEXT_WINDOW", "1048576")
cfg, err := Load(filepath.Join(t.TempDir(), "absent.env"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LLM.ContextWindow != 1048576 {
t.Fatalf("LLM_CONTEXT_WINDOW = %d, want 1048576", cfg.LLM.ContextWindow)
}
}
+14
View File
@@ -66,6 +66,20 @@ func lookupRaw(key, def string, fromEnv map[string]string) string {
return def
}
// envProvided reports whether key was given by the process environment or the
// .env file — the same precedence chain as lookupRaw, only separating out the
// "fell through to the default tag" case. It carries semantics that need to
// tell "unset" from "explicitly zero" (CONTEXT_MAX_TOKENS: 0 = compaction off,
// unset = derive the budget from the model window), which a plain zero value
// cannot express.
func envProvided(key string, fromEnv map[string]string) bool {
if _, ok := os.LookupEnv(key); ok {
return true
}
_, ok := fromEnv[key]
return ok
}
func setField(fv reflect.Value, raw string) error {
switch fv.Kind() {
case reflect.String:
+105
View File
@@ -0,0 +1,105 @@
package config
// 本文件承载多供应商 LLM 与其本地第三级的配置块(design/04 §3.1;裁决 03.4.3)。
// 它从 config.go 拆出来有两条理由:config.go 已贴着 AGENTS §10 的 500 行硬上限,
// 而「LLM 各供应商各自的端点/密钥/模型/推理形状」本来就是一块自洽的配置面。
import "strings"
// LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
// wire protocol; every provider has its own independent base/key/model/fallback
// so multiple vendors can coexist.
type LLMConfig struct {
// Provider declares which protocol to use: "openai" (OpenAI-compatible
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
Provider string `env:"LLM_PROVIDER" default:"openai"`
// OpenAI-compatible provider (default). Kept flat for backward compatibility
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
BaseURL string `env:"LLM_BASE_URL"`
APIKey string `env:"LLM_API_KEY"`
Model string `env:"LLM_MODEL"`
Fallback string `env:"LLM_FALLBACK_MODEL"`
// VisionModel is the dedicated model behind the vision tool (image
// understanding). It is the tool's model, not the main model's ability: the
// per-persona/per-request routing decision belongs to the vision Router, and
// the main model's own image input capability is declared by the active
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
VisionModel string `env:"LLM_VISION_MODEL"`
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
// 的「独立视觉模型 + OCR 模型」)。
OCRModel string `env:"LLM_OCR_MODEL"`
// Multimodal 声明**主模型自身**能否收图:models.json 组 `multimodal` 的 env
// 等价物,只作用于 .env 合成的组,默认 false(语义与分工见 GroupFromEnv)。
Multimodal bool `env:"LLM_MULTIMODAL" default:"false"`
// ContextWindow 声明该端点的**模型上下文窗口**(token,LLM_CONTEXT_WINDOW)。
// 0(默认)表示未声明,此时上下文预算推导回落到
// config.DefaultModelContextWindow 并记一条 WARN(design/04 附录 A.3)。
// 负数在 Load 阶段直接报错(窗口没有负数语义)。
//
// 它是 models.json 组 `contextWindow` 的 env 等价物(per-group 播种,语义见
// runtimeconfig.Materialize);本仓目前没有任何 provider 侧窗口发现代码,
// 因此窗口是**声明式**的: `GET /models` 的自动发现另立设计。
ContextWindow int `env:"LLM_CONTEXT_WINDOW"`
// Local is the optional third degradation level (裁决 03.4.3): primary →
// fallback → local. It defaults to off because the current machine cannot run
// a local model comfortably; when off the chain keeps its two-level shape.
Local LocalLLMConfig
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
// (primary → fallback). It is the final safety budget: it only applies when
// the chain actually has a fallback step, that group sets no per-group
// MaxTokens (models.json maxTokens) and the request carries no explicit
// budget. Zero disables the cap (the adapter default then applies).
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
// the vendor from the model name/base URL and sends nothing when the shape is
// ambiguous (the conservative default, so an existing .env is unchanged);
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
// explicitly. It is the env-fallback twin of the models.json group's
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
// Anthropic native provider, used when Provider=anthropic. Independent
// config block so each vendor is a self-contained config.
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
AnthropicModel string `env:"ANTHROPIC_MODEL"`
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
}
// LocalLLMConfig configures the optional local model rung — the third level of
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
// and defaults to off: a machine that cannot run a local model comfortably must
// not silently pay for one. It becomes an active chain step only when Enabled is
// true and both BaseURL and Model are set; anything less is reported and skipped
// rather than producing a half-configured step.
type LocalLLMConfig struct {
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
// APIKey is the local endpoint's key; empty is normal for a local server.
APIKey string `env:"LLM_LOCAL_API_KEY"`
// Model is the local model name. Without it the rung stays off.
Model string `env:"LLM_LOCAL_MODEL"`
}
// Configured reports whether the local rung has enough configuration to act as a
// chain step. Enabled alone is not enough: a missing endpoint or model must not
// become a step that can only fail.
func (l LocalLLMConfig) Configured() bool {
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
}
@@ -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)},
}}
}