// Package config loads the gateway YAML configuration plus a runtime overlay // (web UI edits) and resolves them into sources with per-model priority. package config import ( "encoding/json" "fmt" "os" "path/filepath" "time" "gopkg.in/yaml.v3" ) // Config is the top-level gateway configuration. type Config struct { Path string `yaml:"-" json:"-"` Listen string `yaml:"listen"` GatewayKeys []string `yaml:"gateway_keys"` DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id AdapterDir string `yaml:"adapter_dir"` RuntimeFile string `yaml:"runtime_file"` TLSCertFile string `yaml:"tls_cert_file,omitempty"` // PEM cert; when set together with tls_key_file, serve HTTPS TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key PublicBaseURL string `yaml:"public_base_url,omitempty"` // external base for generated config snippets; default inferred from request Sources []Source `yaml:"sources"` Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat) AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图) Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理) } // Defaults applied to any source (YAML or runtime) that leaves a field unset. const ( DefaultSourceTimeout = 120 * time.Second DefaultSourceQueueTimeout = 60 * time.Second DefaultSourceConcurrency = 8 ) // Model is a single exposed model id bound to a source, with priority used by // AUTO auto selection (higher number = preferred). type Model struct { ID string `yaml:"id" json:"id"` Priority int `yaml:"priority" json:"priority"` Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image" Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"` } // Source describes a single upstream LLM provider. type Source struct { Name string `yaml:"name" json:"name"` BaseURL string `yaml:"base_url" json:"base_url"` APIKey string `yaml:"api_key" json:"api_key"` APIKeyEnv string `yaml:"api_key_env,omitempty" json:"-"` // reference to an env var holding the key (overrides api_key) Adapter string `yaml:"adapter" json:"adapter"` Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override Models []Model `yaml:"models" json:"models"` Headers map[string]string `yaml:"headers" json:"headers,omitempty"` // ProxyURL routes this source's HTTP(S) traffic through an explicit proxy // (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for // upstreams behind geo/IP blocks that only respond through a proxy. ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"` Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"` Temperature float64 `yaml:"temperature" json:"temperature,omitempty"` MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"` Timeout time.Duration `yaml:"timeout" json:"-"` MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"` QueueTimeout time.Duration `yaml:"queue_timeout" json:"-"` RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited) } // Load reads and validates a config file. When the file does not exist yet a // default config is generated at that path first (first-run bootstrap), so a // fresh binary just works: `llmsproxy -config /path/to/config.yaml`. func Load(path string) (*Config, error) { if _, err := EnsureDefault(path); err != nil { return nil, err } data, err := os.ReadFile(path) if err != nil { return nil, err } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } cfg.Path = path if err := cfg.ApplyDefaults(); err != nil { return nil, err } return &cfg, nil } // EnsureDefault creates a default config file at path when it does not exist // yet and returns whether it was created. The repo ships no config file // (config files carry real keys); the binary generates one per install with a // fresh random admin key. An existing file is never touched. func EnsureDefault(path string) (bool, error) { if _, err := os.Stat(path); err == nil { return false, nil } else if !os.IsNotExist(err) { return false, err } if err := writeDefaultConfig(path); err != nil { return false, err } return true, nil } // writeDefaultConfig writes a minimal, safe-by-default config: loopback-only // listen and a fresh random admin key. No upstream sources are preconfigured // (the repo ships none — add them via WebUI or config.yaml). adapter_dir / // runtime_file live next to the config file so the binary works regardless of // the working directory it is started from. func writeDefaultConfig(path string) error { key, err := NewGatewayKey() if err != nil { return fmt.Errorf("generate gateway key: %w", err) } dir := filepath.Dir(path) abs, err := filepath.Abs(dir) if err != nil { abs = dir } cfg := Config{ Listen: "127.0.0.1:8080", GatewayKeys: []string{key}, DefaultModel: "AUTO", AdapterDir: filepath.Join(abs, "adapters"), RuntimeFile: filepath.Join(abs, "runtime.json"), } out, err := yaml.Marshal(&cfg) if err != nil { return fmt.Errorf("marshal default config: %w", err) } // The config holds the plaintext admin key — restrict permissions. if err := os.MkdirAll(abs, 0755); err != nil { return fmt.Errorf("mkdir config dir: %w", err) } return os.WriteFile(path, out, 0600) } // RemoveSourceFromYAML deletes the named source entry from the config file so // the delete is a real one (no tombstone needed). Uses yaml.Node to preserve // the rest of the file's comments and formatting. func RemoveSourceFromYAML(path, name string) error { data, err := os.ReadFile(path) if err != nil { return err } var doc yaml.Node if err := yaml.Unmarshal(data, &doc); err != nil { return err } content := doc.Content if len(content) == 0 { return nil } root := content[0] if root.Kind != yaml.MappingNode { return nil } for i := 0; i+1 < len(root.Content); i += 2 { key, val := root.Content[i], root.Content[i+1] if key.Value != "sources" || val.Kind != yaml.SequenceNode { continue } kept := val.Content[:0] for _, item := range val.Content { if item.Kind != yaml.MappingNode { continue } found := false for j := 0; j+1 < len(item.Content); j += 2 { if item.Content[j].Value == "name" && item.Content[j+1].Value == name { found = true break } } if !found { kept = append(kept, item) } } val.Content = kept } out, err := yaml.Marshal(&doc) if err != nil { return err } return os.WriteFile(path, out, 0644) } // UpsertSourceInYAML adds or updates a source entry in the YAML config file. // Uses yaml.Node to preserve the rest of the file's comments and formatting. func UpsertSourceInYAML(path, name string, src Source) error { if path == "" { return fmt.Errorf("config path is empty") } data, err := os.ReadFile(path) if err != nil { return err } var doc yaml.Node if err := yaml.Unmarshal(data, &doc); err != nil { return err } content := doc.Content if len(content) == 0 { return nil } root := content[0] if root.Kind != yaml.MappingNode { return nil } // Build yaml.Node for the new source value srcRaw, _ := yaml.Marshal(src) var srcNode yaml.Node yaml.Unmarshal(srcRaw, &srcNode) for i := 0; i+1 < len(root.Content); i += 2 { key, val := root.Content[i], root.Content[i+1] if key.Value != "sources" || val.Kind != yaml.SequenceNode { continue } replaced := false for _, item := range val.Content { if item.Kind != yaml.MappingNode { continue } for j := 0; j+1 < len(item.Content); j += 2 { if item.Content[j].Value == "name" && item.Content[j+1].Value == name { // Replace the existing entry with the new source item.Content = srcNode.Content[0].Content replaced = true break } } if replaced { break } } if !replaced { val.Content = append(val.Content, srcNode.Content[0]) } break } out, err := yaml.Marshal(&doc) if err != nil { return err } return os.WriteFile(path, out, 0644) } // Save writes the current config back to the YAML file (preserving comments // via yaml.Node round-trip when possible, or full marshaling as fallback). func (c *Config) Save() error { if c.Path == "" { return fmt.Errorf("config path is empty") } out, err := yaml.Marshal(c) if err != nil { return fmt.Errorf("marshal config: %w", err) } return os.WriteFile(c.Path, out, 0644) } // ApplyDefaults sets missing values and validates the config. func (c *Config) ApplyDefaults() error { if c.Listen == "" { c.Listen = ":8080" } if c.AdapterDir == "" { c.AdapterDir = "adapters" } if c.RuntimeFile == "" { c.RuntimeFile = "runtime.json" } if c.DefaultModel == "" { c.DefaultModel = "AUTO" } seen := map[string]bool{} for i := range c.Sources { s := &c.Sources[i] if s.Name == "" { return fmt.Errorf("config: sources[%d] missing name", i) } if s.BaseURL == "" { return fmt.Errorf("config: source %s missing base_url", s.Name) } if s.Adapter == "" { s.Adapter = "openai" } if s.Timeout == 0 { s.Timeout = DefaultSourceTimeout } if s.QueueTimeout == 0 { s.QueueTimeout = DefaultSourceQueueTimeout } if s.MaxConcurrent == 0 { s.MaxConcurrent = DefaultSourceConcurrency } if seen[s.Name] { return fmt.Errorf("config: duplicate source name %q", s.Name) } seen[s.Name] = true for j := range s.Models { m := &s.Models[j] if m.ID == "" { return fmt.Errorf("config: source %s has a model without id", s.Name) } // Allow same model ID on multiple sources — disambiguation is via // "source-model" / "source:model" / "source/model" pinning and // the AUTO chain slots carry explicit (model, source) pairs. } } return nil } // RuntimeConfig is the legacy runtime file format (kept for migration only). type RuntimeConfig struct { Sources []Source `json:"sources,omitempty"` SourceTemplates []SourceTemplate `json:"source_templates,omitempty"` DeletedSources []string `json:"deleted_sources,omitempty"` DeletedAdapters []string `json:"deleted_adapters,omitempty"` PresetTemplates []string `json:"preset_templates,omitempty"` // preset names the user has seen (or deleted) — never re-seeded Keys []GWKey `json:"keys,omitempty"` Auto []ModelScope `json:"auto,omitempty"` AutoImage []ModelScope `json:"auto_image,omitempty"` } // SourceTemplate stores reusable source configuration (everything except // name and api_key) so the WebUI can spin up multiple key-bearing sources // from one shared template. type SourceTemplate struct { Name string `json:"name"` BaseURL string `json:"base_url"` Adapter string `json:"adapter"` Endpoint string `json:"endpoint,omitempty"` ImageEndpoint string `json:"image_endpoint,omitempty"` Models []Model `json:"models"` Headers map[string]string `json:"headers,omitempty"` // ProxyURL routes this source's HTTP(S) traffic through an explicit proxy. ProxyURL string `json:"proxy_url,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"` Temperature float64 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` MaxConcurrent int `json:"max_concurrent,omitempty"` RPM int `json:"rpm,omitempty"` } // GWKey is a gateway API key persisted in the config file. Role is "admin" // (full management) or "user" (sees only its own key); Models is the allowed // model scope with per-model token quota (0 = unlimited). type GWKey struct { Key string `yaml:"key" json:"key"` Role string `yaml:"role" json:"role"` Name string `yaml:"name,omitempty" json:"name,omitempty"` Models []ModelScope `yaml:"models,omitempty" json:"models,omitempty"` Note string `yaml:"note,omitempty" json:"note,omitempty"` CreatedAt int64 `yaml:"created_at,omitempty" json:"created_at,omitempty"` Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys } // ModelScope is one allowed model for a key, or one AUTO scheduling slot, // with an optional token quota and reset period. TokenQuota 0 = unlimited; // Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour" // uses Hours as the window length in hours. type ModelScope struct { Model string `yaml:"model" json:"model"` Source string `yaml:"source,omitempty" json:"source,omitempty"` // optional: pin to one upstream source; "" = any source Tier int `yaml:"tier,omitempty" json:"tier,omitempty"` TokenQuota int64 `yaml:"token_quota" json:"token_quota"` Period string `yaml:"period,omitempty" json:"period,omitempty"` Hours int64 `yaml:"hours,omitempty" json:"hours,omitempty"` } // UnmarshalJSON accepts both the legacy "model-id" string form and the // {"model":"...","token_quota":N} object form so old runtime files keep // loading. func (m *ModelScope) UnmarshalJSON(b []byte) error { var s string if err := json.Unmarshal(b, &s); err == nil { m.Model = s return nil } var o struct { Model string `json:"model"` Source string `json:"source"` Tier int `json:"tier"` TokenQuota int64 `json:"token_quota"` Period string `json:"period"` Hours int64 `json:"hours"` } if err := json.Unmarshal(b, &o); err != nil { return err } m.Model = o.Model m.Source = o.Source m.Tier = o.Tier m.TokenQuota = o.TokenQuota m.Period = o.Period m.Hours = o.Hours return nil }