feat: config.yaml only, OpenRouter free models, remove runtime.json config

- Move all config (auto rules, keys) from runtime.json to config.yaml
- Store now only holds runtime sources (WebUI-created)
- Add OpenRouter free models to config.yaml
- One-time migration from legacy runtime.json on startup
- Fix gateway tests for new config structure
- Update core.go with migrateFromRuntime, saveConfig, seedKeys/seedAuto
- Remove SaveKey/KeyByValue/AutoRules from Store
- Add Config.Save() with YAML marshaling
- Update WebUI admin keys visibility (show all keys including admin)
- Bump binary to 11MB with luajit
This commit is contained in:
JianFeeeee
2026-08-13 10:18:19 +08:00
parent 084c9fee2b
commit d06210204b
10 changed files with 307 additions and 225 deletions

View File

@ -13,17 +13,19 @@ import (
// 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"`
MaxConcurrent int `yaml:"max_concurrent"` // global inflight cap, 0 = unlimited
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"`
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"`
MaxConcurrent int `yaml:"max_concurrent"` // global inflight cap, 0 = unlimited
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 优先级页编辑)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥WebUI 密钥页管理)
}
// Defaults applied to any source (YAML or runtime) that leaves a field unset.
@ -128,6 +130,80 @@ func RemoveSourceFromYAML(path, name string) error {
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 == "" {
@ -143,7 +219,6 @@ func (c *Config) ApplyDefaults() error {
c.DefaultModel = "AUTO"
}
seen := map[string]bool{}
modelOwners := map[string]string{}
for i := range c.Sources {
s := &c.Sources[i]
if s.Name == "" {
@ -173,35 +248,34 @@ func (c *Config) ApplyDefaults() error {
if m.ID == "" {
return fmt.Errorf("config: source %s has a model without id", s.Name)
}
if owner, ok := modelOwners[m.ID]; ok {
return fmt.Errorf("config: model %q defined by both %s and %s", m.ID, owner, s.Name)
}
modelOwners[m.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 persisted web-UI editable slice (sources added/edited).
// RuntimeConfig is the legacy runtime file format (kept for migration only).
type RuntimeConfig struct {
Sources []Source `json:"sources"`
Sources []Source `json:"sources,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"`
Keys []GWKey `json:"keys,omitempty"`
Auto []ModelScope `json:"auto,omitempty"`
}
// GWKey is a gateway API key persisted in the runtime store. Role is "admin"
// 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 `json:"key"`
Role string `json:"role"`
Name string `json:"name,omitempty"`
Models []ModelScope `json:"models,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
Seed bool `json:"seed,omitempty"` // true if migrated from config gateway_keys
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,
@ -209,12 +283,12 @@ type GWKey struct {
// Period "" = never resets; "hour"/"week"/"month" are fixed windows; "nhour"
// uses Hours as the window length in hours.
type ModelScope struct {
Model string `json:"model"`
Source string `json:"source,omitempty"` // optional: pin to one upstream source; "" = any source
Tier int `json:"tier,omitempty"`
TokenQuota int64 `json:"token_quota"`
Period string `json:"period,omitempty"`
Hours int64 `json:"hours,omitempty"`
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