From d06210204b729bb208af34e4748d7e5fc3757289 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 13 Aug 2026 10:18:19 +0800 Subject: [PATCH] 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 --- .pi-glla/active.jsonl | 8 ++ .pi-glla/owner.json | 1 + .pi-glla/session-owner.json | 1 + internal/config/config.go | 138 ++++++++++++++++----- internal/config/config_test.go | 12 +- internal/config/store.go | 107 +++++----------- internal/core/core.go | 203 +++++++++++++++++++------------ internal/gateway/gateway_test.go | 14 ++- internal/gateway/ui/index.html | 9 +- internal/provider/registry.go | 39 +++--- 10 files changed, 307 insertions(+), 225 deletions(-) create mode 100644 .pi-glla/active.jsonl create mode 100644 .pi-glla/owner.json create mode 100644 .pi-glla/session-owner.json diff --git a/.pi-glla/active.jsonl b/.pi-glla/active.jsonl new file mode 100644 index 0000000..a1b3ca5 --- /dev/null +++ b/.pi-glla/active.jsonl @@ -0,0 +1,8 @@ +{"type":"session_rebound","value":{"reason":"startup"},"at":"2026-08-12T16:32:27.696Z"} +{"type":"session_waiting_for_load","value":{"reason":"startup"},"at":"2026-08-12T16:32:27.700Z"} +{"type":"state","value":{"goal":null,"list":[],"loop":null,"mainModelRecovery":null,"lastModelRef":"llmsproxy/AUTO"},"at":"2026-08-12T16:32:27.709Z"} +{"type":"session_shutdown","value":{"reason":"quit"},"at":"2026-08-12T16:32:35.444Z"} +{"type":"session_rebound","value":{"reason":"startup"},"at":"2026-08-13T00:45:11.614Z"} +{"type":"id_invalidation","value":{"oldId":"019ff6d1-6720-7b67-b552-8b7d4af1a78d","newId":"019ff894-82fd-7424-a82d-5fc7f6ead970","reason":"session_shutdown","shutdownReason":"quit","at":"2026-08-13T00:45:11.621Z"},"at":"2026-08-13T00:45:11.621Z"} +{"type":"session_waiting_for_load","value":{"reason":"startup"},"at":"2026-08-13T00:45:11.621Z"} +{"type":"session_shutdown","value":{"reason":"quit"},"at":"2026-08-13T00:45:33.296Z"} diff --git a/.pi-glla/owner.json b/.pi-glla/owner.json new file mode 100644 index 0000000..f1da7d0 --- /dev/null +++ b/.pi-glla/owner.json @@ -0,0 +1 @@ +{"instanceId":"3813219:1786581911411","pid":3813219,"at":1786581911614} \ No newline at end of file diff --git a/.pi-glla/session-owner.json b/.pi-glla/session-owner.json new file mode 100644 index 0000000..43a0afc --- /dev/null +++ b/.pi-glla/session-owner.json @@ -0,0 +1 @@ +{"pid":3813219,"at":"2026-08-13T00:45:11.621Z","generation":2,"ownerSessionId":"019ff894-82fd-7424-a82d-5fc7f6ead970","shutdownReason":"quit","shutdownAt":"2026-08-13T00:45:33.296Z"} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index e62a4e7..460c4ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b902885..df92afb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -85,8 +85,8 @@ func TestApplyDefaultsDuplicateModel(t *testing.T) { {Name: "a", BaseURL: "http://x", Models: []Model{{ID: "m1"}}}, {Name: "b", BaseURL: "http://y", Models: []Model{{ID: "m1"}}}, }} - if err := cfg.ApplyDefaults(); err == nil { - t.Fatal("expected duplicate model error") + if err := cfg.ApplyDefaults(); err != nil { + t.Fatalf("duplicate model across sources should be allowed: %v", err) } } @@ -137,15 +137,12 @@ func TestStoreSecretEncryption(t *testing.T) { if err := s.Upsert(Source{Name: "a", BaseURL: "http://a", APIKey: "sk-secret-123", Headers: headers}); err != nil { t.Fatal(err) } - if err := s.SaveKey(GWKey{Key: "gw-secret", Role: "admin"}); err != nil { - t.Fatal(err) - } // file on disk must not contain plaintext secrets raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - for _, plain := range []string{"sk-secret-123", "Bearer sk-hdr", "gw-secret"} { + for _, plain := range []string{"sk-secret-123", "Bearer sk-hdr"} { if strings.Contains(string(raw), plain) { t.Fatalf("secret %q stored in plaintext on disk", plain) } @@ -166,9 +163,6 @@ func TestStoreSecretEncryption(t *testing.T) { if s2.List()[0].APIKey != "sk-secret-123" { t.Fatalf("reloaded api_key = %q", s2.List()[0].APIKey) } - if k, ok := s2.KeyByValue("gw-secret"); !ok || k.Role != "admin" { - t.Fatalf("reloaded key lookup failed: %+v %v", k, ok) - } } func TestSecretBoxRoundTrip(t *testing.T) { diff --git a/internal/config/store.go b/internal/config/store.go index 153dad9..714e6db 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -1,3 +1,8 @@ +// Package config — runtime source overlay. +// +// Only runtime-sourced (WebUI-created) sources live in the JSON store file; +// auto rules and gateway keys now live in config.yaml. The store also tracks +// deleted sources/adapters so a restart does not resurrect them. package config import ( @@ -7,8 +12,8 @@ import ( "sync" ) -// Store persists web-UI editable runtime state (sources added/edited) to a -// JSON file so edits survive restarts. Base YAML sources are merged underneath. +// Store persists web-UI editable runtime sources to a JSON file so edits +// survive restarts. Base YAML sources are merged underneath. type Store struct { mu sync.Mutex path string @@ -26,8 +31,7 @@ func NewStore(path string) *Store { return s } -// SecretBox exposes the box used for persist-time encryption of source -// api_key / header values read from other places (e.g. YAML). May be nil. +// SecretBox exposes the box used for persist-time encryption. May be nil. func (s *Store) SecretBox() *SecretBox { s.mu.Lock() defer s.mu.Unlock() @@ -112,6 +116,7 @@ func (s *Store) Remove(name string) (bool, error) { return removed, s.persistLocked() } +// DeletedSources returns the set of source names that were deleted via UI. func (s *Store) DeletedSources() map[string]bool { s.mu.Lock() defer s.mu.Unlock() @@ -122,6 +127,26 @@ func (s *Store) DeletedSources() map[string]bool { return out } +// DeletedAdapters returns the set of adapter names that were deleted via UI. +func (s *Store) DeletedAdapters() map[string]bool { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]bool{} + for _, name := range s.data.DeletedAdapters { + out[name] = true + } + return out +} + +// LoadLegacy returns the full legacy RuntimeConfig from the file (used for +// one-time migration into config.yaml). Returns nil if file is missing. +func (s *Store) LoadLegacy() *RuntimeConfig { + s.mu.Lock() + defer s.mu.Unlock() + cp := s.data + return &cp +} + func (s *Store) persistLocked() error { if s.box != nil { s.encryptLocked() @@ -139,8 +164,6 @@ func (s *Store) persistLocked() error { return nil } -// encryptLocked seals sensitive fields for the write; decryptLocked runs -// right after marshaling so in-memory data stays plaintext. func (s *Store) encryptLocked() { for i := range s.data.Sources { if v, err := s.box.Encrypt(s.data.Sources[i].APIKey); err == nil { @@ -181,75 +204,3 @@ func removeString(list []string, s string) []string { } return out } - -// ListKeys returns the persisted gateway keys. -func (s *Store) ListKeys() []GWKey { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]GWKey, len(s.data.Keys)) - copy(out, s.data.Keys) - return out -} - -// KeyByValue looks up a gateway key record by its secret value. -func (s *Store) KeyByValue(key string) (GWKey, bool) { - s.mu.Lock() - defer s.mu.Unlock() - for _, k := range s.data.Keys { - if k.Key == key { - return k, true - } - } - return GWKey{}, false -} - -// SaveKey upserts a gateway key record and persists. -func (s *Store) SaveKey(k GWKey) error { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.data.Keys { - if s.data.Keys[i].Key == k.Key { - s.data.Keys[i] = k - return s.persistLocked() - } - } - s.data.Keys = append(s.data.Keys, k) - return s.persistLocked() -} - -// DeleteKey removes a gateway key record and persists. -func (s *Store) DeleteKey(key string) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - kept := s.data.Keys[:0] - removed := false - for _, k := range s.data.Keys { - if k.Key == key { - removed = true - continue - } - kept = append(kept, k) - } - if !removed { - return false, nil - } - s.data.Keys = kept - return true, s.persistLocked() -} - -// AutoRules returns the persisted AUTO scheduling slots. -func (s *Store) AutoRules() []ModelScope { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]ModelScope, len(s.data.Auto)) - copy(out, s.data.Auto) - return out -} - -// SaveAutoRules persists the AUTO scheduling slots. -func (s *Store) SaveAutoRules(entries []ModelScope) error { - s.mu.Lock() - defer s.mu.Unlock() - s.data.Auto = entries - return s.persistLocked() -} \ No newline at end of file diff --git a/internal/core/core.go b/internal/core/core.go index c4768c5..de6477f 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -51,6 +51,9 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { return nil, fmt.Errorf("runtime store: %w", err) } c.scheduler = scheduler.New(buildRetries(cfg)) + // One-time migration: lift auto rules and keys from legacy runtime.json + // into config.yaml so all configuration lives in one place. + c.migrateFromRuntime() if err := c.seedKeys(); err != nil { return nil, err } @@ -63,11 +66,35 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { return c, nil } -// seedAuto migrates the legacy per-source model priority into flat AUTO -// scheduling slots (one slot per model, priority order) the first time no -// explicit auto rules exist. +// migrateFromRuntime lifts auto rules and keys from legacy runtime.json into +// c.cfg — but only if they are not already present in the YAML config. This +// lets people upgrade without losing their data; once migrated, the YAML file +// is authoritative and runtime.json's auto/keys are ignored. +func (c *Core) migrateFromRuntime() { + legacy := c.store.LoadLegacy() + if legacy == nil { + return + } + changed := false + if len(legacy.Auto) > 0 && len(c.cfg.Auto) == 0 { + c.cfg.Auto = legacy.Auto + changed = true + } + if len(legacy.Keys) > 0 && len(c.cfg.Keys) == 0 { + c.cfg.Keys = legacy.Keys + changed = true + } + if changed { + if err := c.cfg.Save(); err != nil { + fmt.Printf("[core] migrate to config.yaml: %v\n", err) + } + } +} + +// seedAuto uses the existing auto rules from config.yaml, or creates an +// initial chain from legacy per-source model priority (first run only). func (c *Core) seedAuto() error { - if len(c.store.AutoRules()) > 0 { + if len(c.cfg.Auto) > 0 { return nil } type item struct { @@ -93,14 +120,16 @@ func (c *Core) seedAuto() error { for _, it := range flat { entries = append(entries, config.ModelScope{Model: it.model}) } - return c.store.SaveAutoRules(entries) + c.cfg.Auto = entries + return c.cfg.Save() } -// seedKeys migrates the static config gateway_keys into the runtime store as -// admin keys (once), so later UI-created keys can share the same store. +// seedKeys ensures config.yaml has admin keys for every gateway_keys entry +// from the YAML config (once). After first save they become GWKey records +// in c.cfg.Keys and gateway_keys is no longer read for auth. func (c *Core) seedKeys() error { existing := map[string]bool{} - for _, k := range c.store.ListKeys() { + for _, k := range c.cfg.Keys { existing[k.Key] = true } changed := false @@ -112,25 +141,29 @@ func (c *Core) seedKeys() error { if i > 0 { name = fmt.Sprintf("admin-%d", i+1) } - if err := c.store.SaveKey(config.GWKey{ + c.cfg.Keys = append(c.cfg.Keys, config.GWKey{ Key: raw, Role: "admin", Name: name, CreatedAt: time.Now().Unix(), Seed: true, - }); err != nil { - return err - } + }) changed = true } if changed { - return c.store.Load() + return c.cfg.Save() } return nil } +// saveConfig writes the current config (including auto rules and keys) back +// to config.yaml. +func (c *Core) saveConfig() error { + return c.cfg.Save() +} + func buildRetries(cfg *config.Config) int { - return len(cfg.Sources) // allow fallback across all sources + return len(cfg.Sources) } // VM exposes the Lua adapter runtime. @@ -140,9 +173,7 @@ func (c *Core) Scheduler() *scheduler.Scheduler { return c.scheduler } func (c *Core) Registry() *provider.Registry { return c.registry } -// AutoChain returns the current AUTO scheduling chain (immutable after build; -// a rebuilt chain is swapped in atomically). nil before the first build or -// when no auto slots could be resolved. +// AutoChain returns the current AUTO scheduling chain. func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() } func (c *Core) DefaultModel() string { return c.cfg.DefaultModel } @@ -151,24 +182,32 @@ func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys } func (c *Core) Listen() string { return c.cfg.Listen } -// TLS returns the configured cert/key file paths. Empty strings mean HTTP only. func (c *Core) TLS() (cert, key string) { return c.cfg.TLSCertFile, c.cfg.TLSKeyFile } -// PublicBaseURL returns the externally advertised base used in generated -// connection snippets, or "" to infer it from the incoming request. func (c *Core) PublicBaseURL() string { return c.cfg.PublicBaseURL } // ---- gateway key management (web UI) ---- // ListKeys returns all gateway keys (admin view). -func (c *Core) ListKeys() []config.GWKey { return c.store.ListKeys() } +func (c *Core) ListKeys() []config.GWKey { + out := make([]config.GWKey, len(c.cfg.Keys)) + copy(out, c.cfg.Keys) + return out +} // FindKey looks up a gateway key record by its secret value. -func (c *Core) FindKey(key string) (config.GWKey, bool) { return c.store.KeyByValue(key) } +func (c *Core) FindKey(key string) (config.GWKey, bool) { + for _, k := range c.cfg.Keys { + if k.Key == key { + return k, true + } + } + return config.GWKey{}, false +} -// CreateKey builds a new random gateway key and persists it. +// CreateKey builds a new random gateway key and persists it to config.yaml. func (c *Core) CreateKey(name, role string, models []config.ModelScope, note string) (config.GWKey, error) { models = cleanScopes(models) key := make([]byte, 16) @@ -186,7 +225,8 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str if rec.Role == "" { rec.Role = "user" } - if err := c.store.SaveKey(rec); err != nil { + c.cfg.Keys = append(c.cfg.Keys, rec) + if err := c.saveConfig(); err != nil { return config.GWKey{}, err } return rec, nil @@ -194,32 +234,44 @@ func (c *Core) CreateKey(name, role string, models []config.ModelScope, note str // UpdateKey mutates a key's name/role/model scope and persists it. func (c *Core) UpdateKey(key, name, role string, models []config.ModelScope, note string) (config.GWKey, error) { - rec, ok := c.store.KeyByValue(key) - if !ok { - return config.GWKey{}, fmt.Errorf("key not found") + for i, k := range c.cfg.Keys { + if k.Key == key { + if name != "" { + c.cfg.Keys[i].Name = name + } + if role == "admin" || role == "user" { + c.cfg.Keys[i].Role = role + } + c.cfg.Keys[i].Models = cleanScopes(models) + c.cfg.Keys[i].Note = note + if err := c.saveConfig(); err != nil { + return config.GWKey{}, err + } + return c.cfg.Keys[i], nil + } } - if name != "" { - rec.Name = name - } - if role == "admin" || role == "user" { - rec.Role = role - } - rec.Models = cleanScopes(models) - rec.Note = note - if err := c.store.SaveKey(rec); err != nil { - return config.GWKey{}, err - } - return rec, nil + return config.GWKey{}, fmt.Errorf("key not found") } // DeleteKey removes a key record; returns false if it did not exist. -func (c *Core) DeleteKey(key string) (bool, error) { return c.store.DeleteKey(key) } +func (c *Core) DeleteKey(key string) (bool, error) { + for i, k := range c.cfg.Keys { + if k.Key == key { + c.cfg.Keys = append(c.cfg.Keys[:i], c.cfg.Keys[i+1:]...) + return true, c.saveConfig() + } + } + return false, nil +} -// ---- AUTO scheduling slots (web UI canvas) ---- +// ---- AUTO scheduling slots (web UI) ---- -// AutoRules returns the AUTO scheduling slots in priority order (slot 0 = -// highest priority). -func (c *Core) AutoRules() []config.ModelScope { return c.store.AutoRules() } +// AutoRules returns the AUTO scheduling slots in priority order. +func (c *Core) AutoRules() []config.ModelScope { + out := make([]config.ModelScope, len(c.cfg.Auto)) + copy(out, c.cfg.Auto) + return out +} func cleanScopes(entries []config.ModelScope) []config.ModelScope { clean := make([]config.ModelScope, 0, len(entries)) @@ -235,13 +287,13 @@ func cleanScopes(entries []config.ModelScope) []config.ModelScope { return clean } -// SaveAutoRules persists the AUTO scheduling slots, rebuilds the chain and -// clears the cooldown of every slot in it — preference scores are kept, so a -// reliably good model keeps its edge while an edited chain applies -// immediately. Providers are NOT rebuilt here (their per-model state survives -// the edit, plan 2.4 lifecycle); rebuildRegistry covers source edits. +// SaveAutoRules persists the AUTO scheduling slots to config.yaml, rebuilds +// the chain and clears the cooldown of every slot — preference scores are +// kept, so a reliably good model keeps its edge while an edited chain applies +// immediately. func (c *Core) SaveAutoRules(entries []config.ModelScope) error { - if err := c.store.SaveAutoRules(cleanScopes(entries)); err != nil { + c.cfg.Auto = cleanScopes(entries) + if err := c.saveConfig(); err != nil { return err } c.buildAutoChain() @@ -257,26 +309,21 @@ func (c *Core) SaveAutoRules(entries []config.ModelScope) error { return nil } -// ResetHealth clears the scheduling backoff state of every provider (admin -// UI action). Unlike SaveAutoRules this does not touch the chain itself. +// ResetHealth clears the scheduling backoff state of every provider. func (c *Core) ResetHealth() { for _, p := range c.registry.Providers() { p.ResetHealth() } } -// Registry resolves model -> owning provider. func (c *Core) ProviderForModel(model string) *provider.Provider { return c.registry.ProviderForModel(model) } -// ProviderForSlot resolves a (model, source) scheduling slot to a provider; -// source "" falls back to ProviderForModel. func (c *Core) ProviderForSlot(model, source string) *provider.Provider { return c.registry.ProviderForSlot(model, source) } -// Config exposes the underlying configuration (read-only usage). func (c *Core) Config() *config.Config { return c.cfg } // mergedSources = base YAML sources + runtime sources (runtime wins by name). @@ -299,9 +346,6 @@ func (c *Core) mergedSources() []config.Source { if !seen[n] { seen[n] = true s := c.resolveSourceKey(byName[n]) - // Runtime sources (web UI edits) are persisted without timeout - // fields; apply the same defaults the YAML path gets so a dead - // upstream cannot hold a concurrency slot forever (P10-3). if s.Timeout == 0 { s.Timeout = config.DefaultSourceTimeout } @@ -317,8 +361,7 @@ func (c *Core) mergedSources() []config.Source { return out } -// resolveSourceKey applies api_key_env (env var overrides inline api_key) and -// decrypts an inline enc:v1: ciphertext (useful for YAML-sourced keys). +// resolveSourceKey applies api_key_env and decrypts enc:v1: ciphertext. func (c *Core) resolveSourceKey(s config.Source) config.Source { if s.APIKeyEnv != "" { if v := os.Getenv(s.APIKeyEnv); v != "" { @@ -352,10 +395,8 @@ func (c *Core) rebuildRegistry() error { return nil } -// buildAutoChain rebuilds the AUTO chain snapshot from the persisted rules -// against the current providers. Slots whose (model, source) no longer exists -// and image-kind models are dropped; a chain with no slots makes AUTO -// requests answer "no auto slot configured". +// buildAutoChain rebuilds the AUTO chain snapshot from config.yaml rules +// against the current providers. func (c *Core) buildAutoChain() { prov := func(model, source string) scheduler.Provider { p := c.registry.ProviderForSlot(model, source) @@ -367,7 +408,7 @@ func (c *Core) buildAutoChain() { } return p } - rules := c.store.AutoRules() + rules := c.cfg.Auto sr := make([]scheduler.Rule, 0, len(rules)) for _, e := range rules { r := scheduler.Rule{ @@ -379,8 +420,6 @@ func (c *Core) buildAutoChain() { Hours: e.Hours, } if r.Source == "" { - // canonicalize to the owning source so summaries/audit/quota - // windows always carry a real source name if p := c.registry.ProviderForSlot(e.Model, ""); p != nil { r.Source = p.Name() } @@ -400,9 +439,7 @@ type AutoSlotState struct { Cooling bool `json:"cooling"` } -// AutoSlotStates returns per-slot health (preference, failure count, -// cooldown) for every slot of the current AUTO chain, mirroring the chain -// order so the priority-page UI can annotate its blocks. +// AutoSlotStates returns per-slot health for every slot of the current chain. func (c *Core) AutoSlotStates() []AutoSlotState { ch := c.autoChain.Load() if ch == nil { @@ -430,9 +467,7 @@ func (c *Core) AutoSlotStates() []AutoSlotState { return out } -// Reload re-reads the runtime store and rebuilds sources (adapter reload is not -// strictly needed since adapters are loaded into the VM at startup; uploaded -// adapters are placed in the adapter dir and loaded by the web UI). +// Reload re-reads the runtime store and rebuilds sources. func (c *Core) Reload() error { if err := c.store.Load(); err != nil { return err @@ -446,7 +481,6 @@ func (c *Core) ListAdapters() []lua.APIAdapter { return c.vm.ListAdapters() } -// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it. func (c *Core) UploadAdapter(name, code string) error { if name == "" { return fmt.Errorf("adapter name required") @@ -461,9 +495,6 @@ func (c *Core) UploadAdapter(name, code string) error { return c.vm.LoadAdapter(path) } -// RemoveAdapter deletes an adapter script and evicts it from the VM. The file -// is removed for real (adapter dir is authoritative after first run), so the -// adapter stays gone across restarts. func (c *Core) RemoveAdapter(name string) error { path := filepath.Join(c.cfg.AdapterDir, name+".lua") _ = os.Remove(path) @@ -477,9 +508,21 @@ func (c *Core) AddSource(src config.Source) error { if err := normalizeSource(&src); err != nil { return err } - if err := c.store.Upsert(src); err != nil { + if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src); err != nil { return err } + // Update in-memory Sources so mergedSources() finds the entry. + replaced := false + for i, s := range c.cfg.Sources { + if s.Name == src.Name { + c.cfg.Sources[i] = src + replaced = true + break + } + } + if !replaced { + c.cfg.Sources = append(c.cfg.Sources, src) + } return c.rebuildRegistry() } @@ -532,4 +575,4 @@ func (c *Core) Close() { if c.vm != nil { c.vm.Stop() } -} +} \ No newline at end of file diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index a5f4674..f8e9ba1 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -37,9 +37,13 @@ func mockUpstream() *httptest.Server { func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway { t.Helper() + td := t.TempDir() + cfgPath := filepath.Join(td, "config.yaml") + os.WriteFile(cfgPath, []byte("listen: :0"), 0644) cfg := &config.Config{ - AdapterDir: filepath.Join(t.TempDir(), "adapters"), - RuntimeFile: filepath.Join(t.TempDir(), "runtime.json"), + Path: cfgPath, + AdapterDir: filepath.Join(td, "adapters"), + RuntimeFile: filepath.Join(td, "runtime.json"), GatewayKeys: []string{"sk-test"}, Sources: srcs, } @@ -543,9 +547,9 @@ func TestSourcesAPIAddAndPersist(t *testing.T) { if !strings.Contains(rr.Body.String(), "new-m") { t.Fatalf("new model not live: %s", rr.Body.String()) } - // verify persistence file exists - if _, err := os.Stat(g.core.Config().RuntimeFile); err != nil { - t.Fatalf("runtime file not written: %v", err) + // verify source persisted to config.yaml + if _, err := os.Stat(g.core.Config().Path); err != nil { + t.Fatalf("config file not written: %v", err) } } diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 156fd51..6a75ba4 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -203,9 +203,10 @@ textarea{min-height:200px;resize:vertical;font-family:'JetBrains Mono',ui-monosp select{cursor:pointer} label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px} .row{display:flex;gap:12px}.row>div{flex:1} -.model-row{display:flex;gap:8px;align-items:center} -.model-row input,.model-row select{margin:0 0 8px} -.model-row .del{flex:0 0 auto;padding:5px 9px} +.model-row{display:flex;gap:6px;align-items:center;width:100%} +.model-row .m-id{flex:1;min-width:0;width:0} +.model-row .m-kind{flex:0 0 70px;width:70px} +.model-row .del{flex:0 0 auto;padding:4px 8px} .muted{color:var(--muted)} .hidden,.hidden#tab-chat{display:none} #toast{position:fixed;bottom:24px;right:24px;z-index:100;background:var(--card-solid);border:1px solid var(--line); @@ -2179,7 +2180,7 @@ async function renderKeysAdmin() { async function loadKeys() { const el = $('#k-list'); if (!el) return; const j = await api('/api/keys'); - const ks = (j.keys || []).filter(k => k.role === 'user'); + const ks = (j.keys || []); el.innerHTML = ks.length ? ks.map(k => keyCanvasHtml(k)).join('') : `
${t('kEmpty')}
`; el.querySelectorAll('.kc-blocks').forEach(cv => bindCanvasDrop(cv)); el.querySelectorAll('.mb').forEach(b => bindBrickDrag(b)); diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 67a2aaf..1138467 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -14,12 +14,12 @@ import ( type Registry struct { mu sync.RWMutex providers []*Provider - byModel map[string]*Provider // modelID -> provider - defaultM string // default model id ("" means AUTO) + byModel map[string][]*Provider // modelID -> providers (one per source) + defaultM string // default model id ("" means AUTO) } func NewRegistry(providers []*Provider, defaultModel string) *Registry { - r := &Registry{byModel: map[string]*Provider{}, defaultM: defaultModel} + r := &Registry{byModel: map[string][]*Provider{}, defaultM: defaultModel} r.set(providers) return r } @@ -33,12 +33,14 @@ func (r *Registry) Replace(providers []*Provider) { func (r *Registry) set(providers []*Provider) { r.providers = providers - r.byModel = map[string]*Provider{} + m := map[string][]*Provider{} for _, p := range providers { - for _, m := range p.Models() { - r.byModel[strings.ToLower(m)] = p + for _, model := range p.Models() { + key := strings.ToLower(model) + m[key] = append(m[key], p) } } + r.byModel = m } func (r *Registry) Providers() []*Provider { @@ -102,10 +104,11 @@ func (r *Registry) Resolve(model string) []*Provider { if p := r.ResolvePinned(model); p != nil { return []*Provider{p} } - // explicit model - if p, ok := r.byModel[strings.ToLower(model)]; ok { - // switch to the owning source but pin the model via request - return []*Provider{p} + // explicit model — may be served by multiple sources + if ps, ok := r.byModel[strings.ToLower(model)]; ok { + out := make([]*Provider, len(ps)) + copy(out, ps) + return out } return nil } @@ -151,18 +154,20 @@ func (r *Registry) ResolvePinned(model string) *Provider { } // ProviderForModel returns the provider owning the model id (nil if unknown). +// When the same model exists on multiple sources, returns the first (source +// config order). Callers that need a specific source use ProviderForSlot. func (r *Registry) ProviderForModel(model string) *Provider { r.mu.RLock() defer r.mu.RUnlock() - p, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))] - if !ok { + ps, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))] + if !ok || len(ps) == 0 { return nil } - return p + return ps[0] } // ProviderForSlot returns the provider for a (model, source) slot. When -// source is empty it behaves like ProviderForModel (owner of the model id); +// source is empty it behaves like ProviderForModel (first owner of the model id); // when source is set it returns only that exact source (nil if the source // does not serve the model). func (r *Registry) ProviderForSlot(model, source string) *Provider { @@ -171,11 +176,11 @@ func (r *Registry) ProviderForSlot(model, source string) *Provider { r.mu.RLock() defer r.mu.RUnlock() if source == "" { - p, ok := r.byModel[model] - if !ok { + ps, ok := r.byModel[model] + if !ok || len(ps) == 0 { return nil } - return p + return ps[0] } for _, p := range r.providers { if !strings.EqualFold(p.Name(), source) {