mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(delete): real deletes — adapters seeded once into adapter_dir (existing dir is authoritative), sources removed from config.yaml on delete; drop tombstone mechanism for both. Docs: multi-key architecture, gateway_keys as seed
This commit is contained in:
@ -13,6 +13,7 @@ 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
|
||||
@ -60,12 +61,63 @@ func Load(path string) (*Config, error) {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
cfg.Path = path
|
||||
if err := cfg.ApplyDefaults(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// RemoveSourceFromYAML deletes the named source entry from the config file so
|
||||
// the delete is a real one (no tombstone needed). Uses yaml.Node to preserve
|
||||
// the rest of the file's comments and formatting.
|
||||
func RemoveSourceFromYAML(path, name string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return err
|
||||
}
|
||||
content := doc.Content
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
root := content[0]
|
||||
if root.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||
key, val := root.Content[i], root.Content[i+1]
|
||||
if key.Value != "sources" || val.Kind != yaml.SequenceNode {
|
||||
continue
|
||||
}
|
||||
kept := val.Content[:0]
|
||||
for _, item := range val.Content {
|
||||
if item.Kind != yaml.MappingNode {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for j := 0; j+1 < len(item.Content); j += 2 {
|
||||
if item.Content[j].Value == "name" && item.Content[j+1].Value == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
kept = append(kept, item)
|
||||
}
|
||||
}
|
||||
val.Content = kept
|
||||
}
|
||||
out, err := yaml.Marshal(&doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, out, 0644)
|
||||
}
|
||||
|
||||
// ApplyDefaults sets missing values and validates the config.
|
||||
func (c *Config) ApplyDefaults() error {
|
||||
if c.Listen == "" {
|
||||
|
||||
@ -196,4 +196,53 @@ func TestSecretBoxRoundTrip(t *testing.T) {
|
||||
if _, err := bad.Decrypt(v); err == nil {
|
||||
t.Fatal("expected decrypt failure with wrong key")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestRemoveSourceFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cfg.yaml")
|
||||
content := `
|
||||
listen: 127.0.0.1:9999
|
||||
gateway_keys: [sk-1]
|
||||
default_model: AUTO
|
||||
adapter_dir: adapters
|
||||
runtime_file: runtime.json
|
||||
sources:
|
||||
- name: deepseek
|
||||
base_url: https://api.deepseek.com
|
||||
api_key: sk-d
|
||||
adapter: deepseek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
- name: ollama
|
||||
base_url: http://127.0.0.1:11434
|
||||
adapter: ollama
|
||||
models:
|
||||
- id: llama3
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RemoveSourceFromYAML(path, "deepseek"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
reloaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if len(reloaded.Sources) != 1 {
|
||||
t.Fatalf("sources = %d, want 1", len(reloaded.Sources))
|
||||
}
|
||||
if reloaded.Sources[0].Name != "ollama" {
|
||||
t.Fatalf("remaining = %q, want ollama", reloaded.Sources[0].Name)
|
||||
}
|
||||
if reloaded.Sources[0].Models[0].ID != "llama3" {
|
||||
t.Fatalf("remaining models broken: %+v", reloaded.Sources[0].Models)
|
||||
}
|
||||
// removing a non-existent name is a no-op that keeps the file valid
|
||||
if err := RemoveSourceFromYAML(path, "nope"); err != nil {
|
||||
t.Fatalf("remove missing: %v", err)
|
||||
}
|
||||
if cfg, err := Load(path); err != nil || len(cfg.Sources) != 1 {
|
||||
t.Fatalf("after no-op: %v %v", len(cfg.Sources), err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,7 +94,8 @@ func (s *Store) Upsert(src Source) error {
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// Remove deletes a runtime source or hides a base YAML source and persists.
|
||||
// Remove deletes a runtime source from the store and persists. Base YAML
|
||||
// sources are handled (truly removed from the config file) by the caller.
|
||||
func (s *Store) Remove(name string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -108,10 +109,7 @@ func (s *Store) Remove(name string) (bool, error) {
|
||||
kept = append(kept, src)
|
||||
}
|
||||
s.data.Sources = kept
|
||||
if !containsString(s.data.DeletedSources, name) {
|
||||
s.data.DeletedSources = append(s.data.DeletedSources, name)
|
||||
}
|
||||
return removed || containsString(s.data.DeletedSources, name), s.persistLocked()
|
||||
return removed, s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) DeletedSources() map[string]bool {
|
||||
@ -124,32 +122,6 @@ func (s *Store) DeletedSources() map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAdapter(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !containsString(s.data.DeletedAdapters, name) {
|
||||
s.data.DeletedAdapters = append(s.data.DeletedAdapters, name)
|
||||
}
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
func (s *Store) RestoreAdapter(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data.DeletedAdapters = removeString(s.data.DeletedAdapters, name)
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) persistLocked() error {
|
||||
if s.box != nil {
|
||||
s.encryptLocked()
|
||||
|
||||
@ -239,13 +239,9 @@ func (c *Core) Config() *config.Config { return c.cfg }
|
||||
|
||||
// mergedSources = base YAML sources + runtime sources (runtime wins by name).
|
||||
func (c *Core) mergedSources() []config.Source {
|
||||
deleted := c.store.DeletedSources()
|
||||
byName := map[string]config.Source{}
|
||||
order := []string{}
|
||||
for _, s := range c.cfg.Sources {
|
||||
if deleted[s.Name] {
|
||||
continue
|
||||
}
|
||||
byName[s.Name] = s
|
||||
order = append(order, s.Name)
|
||||
}
|
||||
@ -313,15 +309,7 @@ func (c *Core) Reload() error {
|
||||
// ---- adapter management (web UI) ----
|
||||
|
||||
func (c *Core) ListAdapters() []lua.APIAdapter {
|
||||
deleted := c.store.DeletedAdapters()
|
||||
list := c.vm.ListAdapters()
|
||||
out := make([]lua.APIAdapter, 0, len(list))
|
||||
for _, a := range list {
|
||||
if !deleted[a.Name] {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return c.vm.ListAdapters()
|
||||
}
|
||||
|
||||
// UploadAdapter saves a new Lua adapter script to the adapter dir and loads it.
|
||||
@ -336,18 +324,17 @@ func (c *Core) UploadAdapter(name, code string) error {
|
||||
if err := os.WriteFile(path, []byte(code), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.vm.LoadAdapter(path); err != nil {
|
||||
return fmt.Errorf("load adapter: %w", err)
|
||||
}
|
||||
return c.store.RestoreAdapter(name)
|
||||
return c.vm.LoadAdapter(path)
|
||||
}
|
||||
|
||||
// RemoveAdapter deletes an adapter script and evicts it from the VM.
|
||||
// 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)
|
||||
c.vm.RemoveAdapter(name)
|
||||
return c.store.DeleteAdapter(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- source management (web UI) ----
|
||||
@ -363,12 +350,31 @@ func (c *Core) AddSource(src config.Source) error {
|
||||
}
|
||||
|
||||
func (c *Core) RemoveSource(name string) error {
|
||||
for _, s := range c.cfg.Sources {
|
||||
if s.Name == name {
|
||||
if err := config.RemoveSourceFromYAML(c.cfg.Path, name); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cfg.Sources = c.removeCfgSource(name)
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := c.store.Remove(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.rebuildRegistry()
|
||||
}
|
||||
|
||||
func (c *Core) removeCfgSource(name string) []config.Source {
|
||||
out := c.cfg.Sources[:0]
|
||||
for _, s := range c.cfg.Sources {
|
||||
if s.Name != name {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Core) Sources() []config.Source { return c.mergedSources() }
|
||||
|
||||
func normalizeSource(s *config.Source) error {
|
||||
|
||||
@ -183,11 +183,20 @@ func (v *VM) Start() error {
|
||||
if v.dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(v.dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir adapter dir: %w", err)
|
||||
// First-run seeding: a brand-new adapter dir is created and populated with
|
||||
// the bundled adapters. If the dir already exists it is treated as
|
||||
// authoritative and never rewritten — deleting a file there is a real delete.
|
||||
firstRun := false
|
||||
if _, err := os.Stat(v.dir); os.IsNotExist(err) {
|
||||
firstRun = true
|
||||
if err := os.MkdirAll(v.dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir adapter dir: %w", err)
|
||||
}
|
||||
}
|
||||
if err := v.writeBundledAdapters(); err != nil {
|
||||
return err
|
||||
if firstRun {
|
||||
if err := v.writeBundledAdapters(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(v.dir)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user