// 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"` } // 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") } return nil } // 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. 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.VisionModel != "", }} } return []Group{{ ID: ProviderOpenAI, Provider: ProviderOpenAI, BaseURL: env.BaseURL, APIKey: env.APIKey, Model: env.Model, Fallback: env.Fallback, Multimodal: env.VisionModel != "", }} } // 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() }