// 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" ) // 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"` // 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"` } // 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 err := validatePersonaMap("personaModels", g.PersonaModels, false); err != nil { return err } if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil { return err } 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, } } return config.LLMConfig{ Provider: ProviderOpenAI, BaseURL: g.BaseURL, APIKey: g.APIKey, Model: g.Model, Fallback: g.Fallback, } } // 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 stays false here: the flat env block has no way to declare whether // the main model accepts image input, and claiming a capability the endpoint may // not have is worse than routing the image through the vision/OCR tool (which is // exactly what the tool exists for). The declaration lives on the models.json // group's `multimodal` flag, which the provider panel writes (design/06 §0 首条; // 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the // main model's ability. 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, }} } return []Group{{ ID: ProviderOpenAI, Provider: ProviderOpenAI, BaseURL: env.BaseURL, APIKey: env.APIKey, Model: env.Model, Fallback: env.Fallback, }} } // 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() }