mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
anthropic.lua v3.0.0: - Issue 1: tool_result/tool_use round-trip - Issue 3: thinking default OFF (opt-in via extra_body.thinking) - Issue 4: tool_choice mapping - Issue 5: collect_blocks preserves unknown part types - message_stop no longer emits done=true (was overwriting tool_calls finish_reason) - cache_read_input_tokens normalized even at 0 gemini.lua: - transform_response was missing cachedContentTokenCount openai.lua (Issue 6): - transform_error handles flat envelopes, nginx HTML, bare text chat.go mergeUsage: - Keep PromptTokensDetails even when CachedTokens=0 scheduler.go: - Remove sort.SliceStable by Pref; round-robin cursor is the only LB mechanism provider.go ModelAvailable: - Also check Pref() > prefMin, persistently failing slots exit cands presets.go: - 17 built-in source templates Tests: 6 new test functions, 2 updated for new semantics
273 lines
6.7 KiB
Go
273 lines
6.7 KiB
Go
// 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 (
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"sync"
|
|
)
|
|
|
|
// 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
|
|
data RuntimeConfig
|
|
box *SecretBox
|
|
}
|
|
|
|
func NewStore(path string) *Store {
|
|
s := &Store{path: path}
|
|
if box, err := NewSecretBox(path); err == nil {
|
|
s.box = box
|
|
} else {
|
|
log.Printf("[config] secrets disabled: %v (sensitive values will be stored in plaintext)", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SecretBox exposes the box used for persist-time encryption. May be nil.
|
|
func (s *Store) SecretBox() *SecretBox {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.box
|
|
}
|
|
|
|
// Load reads the runtime file (missing file = empty state).
|
|
func (s *Store) Load() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
s.data = RuntimeConfig{}
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(data, &s.data); err != nil {
|
|
return err
|
|
}
|
|
if s.box != nil {
|
|
s.decryptLocked()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decryptLocked replaces persisted ciphertext fields with their plaintext in
|
|
// memory (the runtime always works with plaintext; only the file is sealed).
|
|
func (s *Store) decryptLocked() {
|
|
for i := range s.data.Sources {
|
|
s.data.Sources[i].APIKey = s.box.MustDecrypt(s.data.Sources[i].APIKey)
|
|
for k, v := range s.data.Sources[i].Headers {
|
|
s.data.Sources[i].Headers[k] = s.box.MustDecrypt(v)
|
|
}
|
|
}
|
|
for i := range s.data.Keys {
|
|
s.data.Keys[i].Key = s.box.MustDecrypt(s.data.Keys[i].Key)
|
|
}
|
|
}
|
|
|
|
// List returns the runtime sources (those edited via web UI).
|
|
func (s *Store) List() []Source {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]Source, len(s.data.Sources))
|
|
copy(out, s.data.Sources)
|
|
return out
|
|
}
|
|
|
|
// Upsert adds or replaces a runtime source and persists.
|
|
func (s *Store) Upsert(src Source) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.Sources {
|
|
if s.data.Sources[i].Name == src.Name {
|
|
s.data.Sources[i] = src
|
|
s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name)
|
|
return s.persistLocked()
|
|
}
|
|
}
|
|
s.data.Sources = append(s.data.Sources, src)
|
|
s.data.DeletedSources = removeString(s.data.DeletedSources, src.Name)
|
|
return s.persistLocked()
|
|
}
|
|
|
|
// 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()
|
|
kept := s.data.Sources[:0]
|
|
removed := false
|
|
for _, src := range s.data.Sources {
|
|
if src.Name == name {
|
|
removed = true
|
|
continue
|
|
}
|
|
kept = append(kept, src)
|
|
}
|
|
s.data.Sources = kept
|
|
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()
|
|
out := map[string]bool{}
|
|
for _, name := range s.data.DeletedSources {
|
|
out[name] = true
|
|
}
|
|
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
|
|
}
|
|
|
|
// SeedTemplates adds preset templates that the store has never seen before.
|
|
// A preset is seeded at most once: its name is recorded in PresetTemplates so
|
|
// a user who edits or deletes it never gets it silently restored on restart.
|
|
func (s *Store) SeedTemplates(presets []SourceTemplate) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
seen := make(map[string]bool, len(s.data.PresetTemplates))
|
|
for _, n := range s.data.PresetTemplates {
|
|
seen[n] = true
|
|
}
|
|
existing := make(map[string]bool, len(s.data.SourceTemplates))
|
|
for _, t := range s.data.SourceTemplates {
|
|
existing[t.Name] = true
|
|
}
|
|
added := false
|
|
for _, p := range presets {
|
|
if p.Name == "" || seen[p.Name] {
|
|
continue
|
|
}
|
|
s.data.PresetTemplates = append(s.data.PresetTemplates, p.Name)
|
|
added = true
|
|
if existing[p.Name] {
|
|
continue // user already has a template by this name: never overwrite
|
|
}
|
|
s.data.SourceTemplates = append(s.data.SourceTemplates, p)
|
|
}
|
|
if !added {
|
|
return nil
|
|
}
|
|
return s.persistLocked()
|
|
}
|
|
|
|
// UpsertTemplate adds or replaces a source template and persists.
|
|
func (s *Store) UpsertTemplate(t SourceTemplate) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.SourceTemplates {
|
|
if s.data.SourceTemplates[i].Name == t.Name {
|
|
s.data.SourceTemplates[i] = t
|
|
return s.persistLocked()
|
|
}
|
|
}
|
|
s.data.SourceTemplates = append(s.data.SourceTemplates, t)
|
|
return s.persistLocked()
|
|
}
|
|
|
|
// ListTemplates returns the saved source templates.
|
|
func (s *Store) ListTemplates() []SourceTemplate {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]SourceTemplate, len(s.data.SourceTemplates))
|
|
copy(out, s.data.SourceTemplates)
|
|
return out
|
|
}
|
|
|
|
// RemoveTemplate deletes a source template by name and persists.
|
|
func (s *Store) RemoveTemplate(name string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
kept := s.data.SourceTemplates[:0]
|
|
removed := false
|
|
for _, t := range s.data.SourceTemplates {
|
|
if t.Name == name {
|
|
removed = true
|
|
continue
|
|
}
|
|
kept = append(kept, t)
|
|
}
|
|
s.data.SourceTemplates = kept
|
|
if removed {
|
|
return true, s.persistLocked()
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
b, err := json.MarshalIndent(s.data, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.box != nil {
|
|
s.decryptLocked()
|
|
}
|
|
if err := os.WriteFile(s.path, b, 0644); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) encryptLocked() {
|
|
for i := range s.data.Sources {
|
|
if v, err := s.box.Encrypt(s.data.Sources[i].APIKey); err == nil {
|
|
s.data.Sources[i].APIKey = v
|
|
}
|
|
h := s.data.Sources[i].Headers
|
|
for k, v := range h {
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if e, err := s.box.Encrypt(v); err == nil {
|
|
h[k] = e
|
|
}
|
|
}
|
|
}
|
|
for i := range s.data.Keys {
|
|
if v, err := s.box.Encrypt(s.data.Keys[i].Key); err == nil {
|
|
s.data.Keys[i].Key = v
|
|
}
|
|
}
|
|
}
|
|
|
|
func removeString(list []string, s string) []string {
|
|
out := list[:0]
|
|
for _, x := range list {
|
|
if x != s {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|