Files
ModelRouter/internal/config/store.go
JianFeeeee 624fd74b45 fix: anthropic tool-call round-trip, cache zero-hit parity, round-robin load balancing
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
2026-08-28 12:02:46 +08:00

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
}