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)。
This commit is contained in:
1 parent
5b2b385524
commit
72b95df22b
39 files changed
+2496
-377
No files matched your search
@@ -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
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 未声明窗口却把该键写进了面板配置")
|
||||
}
|
||||
}
|
||||
@@ -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 的那个键,落在哪个组上。
|
||||
|
||||
@@ -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)},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user