mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
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:
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user