// 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() }