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

8
.pi-glla/active.jsonl Normal file
View File

@ -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"}

1
.pi-glla/owner.json Normal file
View File

@ -0,0 +1 @@
{"instanceId":"3813219:1786581911411","pid":3813219,"at":1786581911614}

View File

@ -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"}

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

View File

@ -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) {

View File

@ -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()
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -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('') : `<div class="muted">${t('kEmpty')}</div>`;
el.querySelectorAll('.kc-blocks').forEach(cv => bindCanvasDrop(cv));
el.querySelectorAll('.mb').forEach(b => bindBrickDrag(b));

View File

@ -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) {