Files
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。

- 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。

- usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。

- 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。

- 顺手修越界:config.go 505 行(超 500)拆出 llm.go。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
2026-10-04 17:57:08 +08:00

456 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package runtimeconfig loads and persists the runtime-editable provider
// configuration: the independent LLM vendor groups in models.json (design/04
// §3.1). The .env LLM block is the final fallback; a present-and-valid
// models.json takes precedence and is hot-reloaded at runtime without a restart.
//
// models.json is gitignored (local runtime config) and holds API keys, so this
// package never logs a key and never exposes one un-masked in a Config().
package runtimeconfig
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Provider names recognized in models.json. They mirror the model adapter
// factory's protocol names so a group maps directly to a wire protocol.
const (
ProviderOpenAI = "openai"
ProviderAnthropic = "anthropic"
// modelsFileName is the models.json basename stored under the data dir.
modelsFileName = "models.json"
)
// ReasoningKind* 是 provider 级推理开关形状的合法取值(裁决 03.4.2 / 08.4.1:
// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配)。这些常量是
// 形状名的**唯一来源**:配置层(本包)校验它们,模型适配器按它们选 wire 字段,
// 面板按它们渲染下拉框。
//
// 取值刻意用**部署形态**命名(vllm / dashscope)而不是厂商名:同一家厂商的
// 模型在不同形态下的字段形状不同(vLLM 用 chat_template_kwargs.enable_thinking,
// DashScope 兼容模式用顶层 enable_thinking),形状由端点决定,不是由模型名决定。
const (
// ReasoningKindAuto 是缺省值:按模型名/BaseURL 推断厂商,形状有歧义时
// 不下发任何开关(只报告),因此它对旧配置零行为变化。
ReasoningKindAuto = "auto"
// ReasoningKindDeepSeek 是 DeepSeek:顶层 thinking:{type} + reasoning_effort。
ReasoningKindDeepSeek = "deepseek"
// ReasoningKindOpenAI 是 OpenAI 系:只有 reasoning_effort,无布尔开关。
ReasoningKindOpenAI = "openai"
// ReasoningKindVLLM 是 vLLM 类部署(承载 Qwen3 等):
// chat_template_kwargs.enable_thinking。
ReasoningKindVLLM = "vllm"
// ReasoningKindDashScope 是 DashScope 兼容模式:顶层 enable_thinking。
ReasoningKindDashScope = "dashscope"
)
// ReasoningKinds 返回本 build 接受的 reasoning.kind 取值全集(auto 在首位,
// 与面板下拉框的推荐顺序一致)。它是**校验与面板的同一来源**:管理端点把这份
// 清单随配置一起下发,面板据此渲染下拉框,因此「面板能选的」与「配置层接受的」
// 不可能漂移。空串(未声明)不在此列——它是「不声明形状」而不是一个形状名,
// 面板另行给出该选项。返回新切片,调用方改不动包内状态。
func ReasoningKinds() []string {
return []string{
ReasoningKindAuto,
ReasoningKindDeepSeek,
ReasoningKindOpenAI,
ReasoningKindVLLM,
ReasoningKindDashScope,
}
}
// ProviderNames 返回本 build 承认的 wire 协议名,用作 per-persona 路由
// (personaProviders)的合法值域。与面板共用同一来源,避免面板给出一个
// 配置层会拒收的协议名。
func ProviderNames() []string {
return []string{ProviderOpenAI, ProviderAnthropic}
}
// ReasoningOptions 是一个 provider 组的推理开关形状声明。它是「按提供商
// 独立适配」的配置载体(裁决 03.4.2 / 08.4.1):面板与 models.json 对每个
// 供应商各写一份,因此 vLLM 端点与 DashScope 端点可以同时存在、各自下发
// 自己形状的开关,而不是被套进一个「通用」形状。默认(零值)保守:不声明
// 形状就不下发开关,只报告——发错形状会 400,比不发更糟。
type ReasoningOptions struct {
// Kind 显式声明该端点的思考开关形状(见 ReasoningKind* 常量)。
// 空或 "auto" = 按模型名/BaseURL 推断厂商(形状有歧义时不下发)。
Kind string `json:"kind,omitempty"`
}
// Group is one independent multi-supplier LLM configuration. Each vendor's
// base/key/model/fallback is a self-contained unit, so multiple vendors coexist
// and Provider selects the active wire protocol.
type Group struct {
ID string `json:"id"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Fallback string `json:"fallback,omitempty"`
Multimodal bool `json:"multimodal,omitempty"`
// MaxTokens is the group's own token budget, applied to every model in the
// group's fallback chain (primary and fallback). Zero means "unset": the
// 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
// models.json rather than configs/personas/*.yaml, so the panel can edit the
// binding together with the rest of the provider configuration and no persona
// schema change is needed. An unknown persona id falls back to the group's
// default model, so a removed persona never breaks routing.
PersonaModels map[string]string `json:"personaModels,omitempty"`
// PersonaProviders binds an individual persona to another vendor's protocol
// (裁决 03.4.1 的 per-persona provider). Pair it with PersonaModels; an entry
// without a provider keeps this group's protocol.
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
// Reasoning declares this provider/endpoint's reasoning-switch wire shape
// (裁决 03.4.2 / 08.4.1). It is a provider-level option because the shape
// belongs to the endpoint, not to a persona: every group carries its own, so
// two vendors with different switch shapes coexist without a shared guess.
// omitzero keeps an undeclared shape out of models.json entirely (an empty
// object would be schema noise for every group).
Reasoning ReasoningOptions `json:"reasoning,omitzero"`
}
// ErrInvalidGroup is returned when a group fails schema validation.
var ErrInvalidGroup = errors.New("runtimeconfig: invalid model group")
// DefaultPath returns the models.json path under dataDir.
func DefaultPath(dataDir string) string {
return filepath.Join(dataDir, modelsFileName)
}
// Validate checks the group's required fields. It fails loudly so a bad group
// never silently produces a half-configured adapter.
func (g Group) Validate() error {
if strings.TrimSpace(g.ID) == "" {
return fmt.Errorf("id is required")
}
if g.Provider != ProviderOpenAI && g.Provider != ProviderAnthropic {
return fmt.Errorf("provider %q must be openai or anthropic", g.Provider)
}
if strings.TrimSpace(g.BaseURL) == "" {
return fmt.Errorf("baseUrl is required")
}
if strings.TrimSpace(g.Model) == "" {
return fmt.Errorf("model is required")
}
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
}
if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil {
return err
}
return validateReasoning(g.Provider, g.Reasoning)
}
// validateReasoning checks a provider-level reasoning shape declaration. A blank
// or "auto" kind is always valid (the conservative default: infer from the model
// name/base URL, and send nothing when the shape is ambiguous). Any other kind
// must be a shape this build implements and has evidence for; and the
// openai-compatible shape names are rejected on an anthropic group, whose switch
// shape is defined by its own protocol — failing loudly beats a silently inert
// option.
func validateReasoning(provider string, r ReasoningOptions) error {
kind := strings.TrimSpace(r.Kind)
switch kind {
case "", ReasoningKindAuto, ReasoningKindDeepSeek, ReasoningKindOpenAI,
ReasoningKindVLLM, ReasoningKindDashScope:
default:
return fmt.Errorf("reasoning.kind %q must be one of %s", kind, strings.Join(ReasoningKinds(), "/"))
}
if provider == ProviderAnthropic && kind != "" && kind != ReasoningKindAuto {
return fmt.Errorf("reasoning.kind %q applies to the openai-compatible provider only", kind)
}
return nil
}
// validatePersonaMap checks a persona-keyed binding map: every key must be a
// non-empty persona id and every value a non-empty value. When providers is true
// the values must additionally be a known protocol, so a typo in a per-persona
// provider fails the group loudly instead of silently routing to the default.
func validatePersonaMap(field string, m map[string]string, providers bool) error {
for persona, value := range m {
if strings.TrimSpace(persona) == "" {
return fmt.Errorf("%s: persona id must not be empty", field)
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s: value for persona %q must not be empty", field, persona)
}
if providers && value != ProviderOpenAI && value != ProviderAnthropic {
return fmt.Errorf("%s: provider %q for persona %q must be openai or anthropic", field, value, persona)
}
}
return nil
}
// PersonaRoute returns the per-persona model/provider binding, if one is
// configured for persona. An empty model (or an unknown persona) is reported as
// ok=false so the caller keeps the group's default binding.
func (g Group) PersonaRoute(persona string) (model, provider string, ok bool) {
persona = strings.TrimSpace(persona)
if persona == "" {
return "", "", false
}
model = strings.TrimSpace(g.PersonaModels[persona])
if model == "" {
return "", "", false
}
return model, strings.TrimSpace(g.PersonaProviders[persona]), true
}
// ToLLMConfig maps a group to the flat LLMConfig consumed by the adapter
// factory. The group's provider selects which config block is populated.
func (g Group) ToLLMConfig() config.LLMConfig {
if g.Provider == ProviderAnthropic {
return config.LLMConfig{
Provider: ProviderAnthropic,
AnthropicBaseURL: g.BaseURL,
AnthropicAPIKey: g.APIKey,
AnthropicModel: g.Model,
AnthropicFallback: g.Fallback,
ReasoningKind: strings.TrimSpace(g.Reasoning.Kind),
}
}
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: g.BaseURL,
APIKey: g.APIKey,
Model: g.Model,
Fallback: g.Fallback,
ReasoningKind: strings.TrimSpace(g.Reasoning.Kind),
}
}
// MaskedKey returns a redacted form of the API key for display/logging. It never
// reveals more than the trailing 4 characters, so a key never lands verbatim in
// a log line or a wire response.
func (g Group) MaskedKey() string {
k := strings.TrimSpace(g.APIKey)
if k == "" {
return ""
}
if len(k) <= 4 {
return "****"
}
return "****" + k[len(k)-4:]
}
// LoadModels reads path and returns the effective LLM groups. When the file is
// absent it synthesizes a single group from env (the .env fallback). A
// present-but-invalid file falls back to env and returns a non-nil warning so
// the caller can log it; the returned groups are always non-empty.
func LoadModels(path string, env config.LLMConfig) ([]Group, error) {
files, readErr := loadFile(path)
if readErr == nil {
return files, nil
}
// A missing file is the normal case (gitignored runtime config); only a
// present-but-invalid file warrants a warning to the caller.
var warn error
if !os.IsNotExist(readErr) {
warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr)
}
return GroupFromEnv(env), warn
}
// loadFile decodes and validates the models.json array. A nil/blank file, a
// JSON decode error or an empty array all return an error so the caller falls
// back to env.
func loadFile(path string) ([]Group, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidGroup)
}
var groups []Group
dec := json.NewDecoder(strings.NewReader(string(raw)))
if err := dec.Decode(&groups); err != nil {
return nil, fmt.Errorf("%w: decode: %v", ErrInvalidGroup, err)
}
if len(groups) == 0 {
return nil, fmt.Errorf("%w: no groups", ErrInvalidGroup)
}
for i, g := range groups {
if err := g.Validate(); err != nil {
return nil, fmt.Errorf("%w: group %d: %v", ErrInvalidGroup, i, err)
}
}
return groups, nil
}
// GroupFromEnv synthesizes a single group from the active env LLM block: the
// anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible
// block. It is the .env fallback used when models.json is absent or invalid.
//
// Multimodal 来自 LLM_MULTIMODAL,默认 false。语义(裁决 14.4.1):它是本组
// `multimodal` 字段的 **env 声明**——「主模型自身能否收图」,而不是「本部署有没有
// 视觉能力」。默认 false 与引入本键之前的行为逐字节一致:
// 扁平 env 块只有这一个键能声明收图能力,而**声称端点可能不具备的能力,比把图
// 交给 vision/OCR 工具更糟**——工具至少会返回一段文本描述(design/06 §0 首条)。
// 因此只有显式写 `LLM_MULTIMODAL=true` 才让主模型直接收图,**绝不凭 baseURL /
// 模型名猜测**(与 reasoning.kind 的口径一致:声明才生效,不推断)。LLM_VISION_MODEL
// / LLM_OCR_MODEL 配的是**视觉/OCR 工具调用的模型**,与本组能否收图互不代表。
//
// 非法值(不是 bool,如 `maybe`)在 config.Load 解码阶段**直接报错**,启动失败,
// 不静默回退成 false——静默的「配了当没配」正是本键要消灭的东西。
//
// Reasoning.Kind is carried through so an env-only deployment can still declare
// its endpoint's switch shape (LLM_REASONING_KIND); its env default is blank,
// which keeps every existing deployment at "auto" (no new field is ever sent).
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,
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,
ContextWindow: env.ContextWindow,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}
// Find returns the group with the given id, if present. It is the read half of
// the admin read-modify-write merge, so a partial edit starts from the stored
// group instead of zero values.
func Find(groups []Group, id string) (Group, bool) {
if strings.TrimSpace(id) == "" {
return Group{}, false
}
for _, g := range groups {
if g.ID == id {
return g, true
}
}
return Group{}, false
}
// Upsert returns a copy of groups with g inserted or replaced in place by ID,
// preserving order. A blank ID is assigned a generated id so a panel save
// without an explicit id still produces a stable, addressable group.
func Upsert(groups []Group, g Group) []Group {
if strings.TrimSpace(g.ID) == "" {
g.ID = NewID()
}
out := make([]Group, 0, len(groups)+1)
replaced := false
for _, cur := range groups {
if cur.ID == g.ID {
out = append(out, g)
replaced = true
continue
}
out = append(out, cur)
}
if !replaced {
out = append(out, g)
}
return out
}
// Activate returns groups with g promoted to the front (index 0 = the group a
// new request uses by default). An existing group with the same id is replaced
// and re-prepended, so a panel save both updates the group and makes it the live
// provider. A blank ID is assigned a generated id.
func Activate(groups []Group, g Group) []Group {
if strings.TrimSpace(g.ID) == "" {
g.ID = NewID()
}
out := make([]Group, 0, len(groups)+1)
out = append(out, g)
for _, cur := range groups {
if cur.ID == g.ID {
continue
}
out = append(out, cur)
}
return out
}
// SaveModels writes groups as a pretty-printed JSON array to path with mode
// 0600 (the file holds API keys). It writes to a temp file then renames so a
// concurrent reader never sees a partial file. Every group is validated first.
func SaveModels(path string, groups []Group) error {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
}
}
for _, g := range groups {
if err := g.Validate(); err != nil {
return fmt.Errorf("runtimeconfig: save: %w", err)
}
}
raw, err := json.MarshalIndent(groups, "", " ")
if err != nil {
return fmt.Errorf("runtimeconfig: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename: %w", err)
}
return nil
}
// NewID returns a 16-byte hex id, used for a group saved without an explicit id.
func NewID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", newTimeUnixNano())
}
return hex.EncodeToString(b)
}
// newTimeUnixNano returns the current unix-nano as an integer, as a fallback id
// source when the crypto RNG fails.
func newTimeUnixNano() int64 {
return time.Now().UnixNano()
}