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:
23
README.md
23
README.md
@ -68,7 +68,7 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \
|
||||
http://127.0.0.1:8080/v1/chat/completions
|
||||
```
|
||||
|
||||
任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用 `gateway_keys` 之一即可。
|
||||
任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用任意一个已创建并授权的网关 key 即可。
|
||||
|
||||
## 配置
|
||||
|
||||
@ -76,10 +76,10 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \
|
||||
|
||||
```yaml
|
||||
listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环)
|
||||
gateway_keys: [sk-gw-0001] # 客户端访问网关的 key;留空=不鉴权
|
||||
default_model: AUTO # model 无法路由时自动按 priority 选源
|
||||
adapter_dir: adapters # Lua 适配目录,首启自动写入内置适配器
|
||||
runtime_file: runtime.json # WebUI 编辑的源持久化到此文件
|
||||
gateway_keys: [sk-gw-0001] # 初始 admin 密钥种子,仅首启时写入运行时存储用
|
||||
default_model: AUTO # model 无法路由时自动按优先级链选源
|
||||
adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读
|
||||
runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件
|
||||
|
||||
sources:
|
||||
- name: deepseek
|
||||
@ -111,6 +111,19 @@ sources:
|
||||
- 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。
|
||||
- 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。
|
||||
|
||||
### 网关密钥(多密钥)
|
||||
|
||||
网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的
|
||||
`keys` 字段(加密存储):
|
||||
|
||||
- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin
|
||||
key,之后不再参与鉴权管理。
|
||||
- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或
|
||||
`user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 +
|
||||
重置周期)。
|
||||
- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer <key>`)。
|
||||
- 删除密钥即从运行时存储移除,立即失效。
|
||||
|
||||
### 模型路由
|
||||
|
||||
`/v1/chat/completions` 的 `model` 解析顺序:
|
||||
|
||||
26
README_EN.md
26
README_EN.md
@ -88,8 +88,8 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \
|
||||
http://127.0.0.1:8080/v1/chat/completions
|
||||
```
|
||||
|
||||
Any OpenAI SDK works: point `base_url` at the gateway, use one of `gateway_keys`
|
||||
as the API key.
|
||||
Any OpenAI SDK works: point `base_url` at the gateway, use any authorized
|
||||
gateway key as the API key.
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -97,10 +97,10 @@ See [`config.example.yaml`](config.example.yaml). Core fields:
|
||||
|
||||
```yaml
|
||||
listen: 127.0.0.1:8080 # bind address (keep internal/loopback)
|
||||
gateway_keys: [sk-gw-0001] # keys clients use; empty = no auth
|
||||
default_model: AUTO # when model is unroutable, pick source by priority
|
||||
adapter_dir: adapters # Lua adapter dir; built-ins written on first start
|
||||
runtime_file: runtime.json # WebUI-edited sources persist here
|
||||
gateway_keys: [sk-gw-0001] # initial admin key seed, migrated to the store on first start
|
||||
default_model: AUTO # when model is unroutable, follow the AUTO chain
|
||||
adapter_dir: adapters # Lua adapter dir; created+seeded if missing, read-only otherwise
|
||||
runtime_file: runtime.json # WebUI-edited sources/keys/AUTO chain persist here
|
||||
|
||||
sources:
|
||||
- name: deepseek
|
||||
@ -135,6 +135,20 @@ Sensitive fields in the runtime file (`runtime_file`) are encrypted at rest:
|
||||
- Back up `master.key` with your config — losing it makes the secrets
|
||||
undecryptable. Do not commit it.
|
||||
|
||||
### Gateway keys (multi-key)
|
||||
|
||||
Gateway auth uses a "multi-key + role + model scope" model. Keys are persisted
|
||||
under the `keys` field of the runtime file (encrypted at rest):
|
||||
|
||||
- The `gateway_keys` config is only an **initial admin key seed** — it is
|
||||
migrated into the runtime store on first start and no longer drives auth.
|
||||
- The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin`
|
||||
manages everything, `user` sees only its own key) and an optional **model
|
||||
scope** (model + source + token quota + reset period).
|
||||
- Clients authenticate with any authorized key's plaintext as
|
||||
`Authorization: Bearer <key>`.
|
||||
- Deleting a key removes it from the store immediately.
|
||||
|
||||
### Model routing
|
||||
|
||||
`/v1/chat/completions` `model` resolution order:
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
# 网关监听地址(默认 :8080,建议绑内网/回环)
|
||||
listen: 127.0.0.1:8080
|
||||
|
||||
# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。
|
||||
# 网关自身鉴权密钥。现在为「多密钥」架构:此项仅作为初始 admin 密钥种子,
|
||||
# 首次启动写入运行时存储(runtime_file 的 keys 字段,加密存储)。之后请在
|
||||
# WebUI「密钥」页创建、删除密钥并配置其模型范围。留空 = 首启无 admin 密钥。
|
||||
gateway_keys:
|
||||
- sk-gw-local-0001
|
||||
|
||||
|
||||
@ -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