499 lines
13 KiB
Go
499 lines
13 KiB
Go
// Package config manages the local ocswitch JSON config file.
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/Apale7/opencode-provider-switch/internal/fileutil"
|
|
)
|
|
|
|
const (
|
|
AppName = "ocswitch"
|
|
ConfigEnvVar = "OCSWITCH_CONFIG"
|
|
ConfigDirName = "ocswitch"
|
|
DefaultLocalAPIKey = "ocswitch-local"
|
|
)
|
|
|
|
// Target is one concrete upstream candidate for an alias.
|
|
type Target struct {
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// Alias maps a logical model name to ordered upstream targets.
|
|
type Alias struct {
|
|
Alias string `json:"alias"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
Targets []Target `json:"targets"`
|
|
}
|
|
|
|
// Provider is one upstream OpenAI-compatible endpoint.
|
|
type Provider struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
Models []string `json:"models,omitempty"`
|
|
ModelsSource string `json:"models_source,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// Server holds proxy listen settings.
|
|
type Server struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
|
|
// Config is the on-disk ocswitch config.
|
|
type Config struct {
|
|
Server Server `json:"server"`
|
|
Providers []Provider `json:"providers"`
|
|
Aliases []Alias `json:"aliases"`
|
|
|
|
path string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// IsEnabled reports whether the provider can be used for routing.
|
|
func (p Provider) IsEnabled() bool {
|
|
return !p.Disabled
|
|
}
|
|
|
|
// NormalizeProviderBaseURL canonicalizes equivalent /v1 roots for comparisons
|
|
// and persisted config values.
|
|
func NormalizeProviderBaseURL(baseURL string) string {
|
|
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
}
|
|
|
|
// ValidateProviderBaseURL checks the MVP requirement that upstream base URLs
|
|
// point at an OpenAI-compatible /v1 root.
|
|
func ValidateProviderBaseURL(baseURL string) error {
|
|
trimmed := NormalizeProviderBaseURL(baseURL)
|
|
if trimmed == "" {
|
|
return fmt.Errorf("missing base_url")
|
|
}
|
|
if !strings.HasSuffix(trimmed, "/v1") {
|
|
return fmt.Errorf("base_url must end with /v1")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Default returns an empty config with sane defaults.
|
|
func Default() *Config {
|
|
return &Config{
|
|
Server: Server{
|
|
Host: "127.0.0.1",
|
|
Port: 9982,
|
|
APIKey: DefaultLocalAPIKey,
|
|
},
|
|
Providers: []Provider{},
|
|
Aliases: []Alias{},
|
|
}
|
|
}
|
|
|
|
// DefaultPath returns ~/.config/ocswitch/config.json (XDG aware).
|
|
func DefaultPath() string {
|
|
if p := os.Getenv(ConfigEnvVar); p != "" {
|
|
return p
|
|
}
|
|
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
|
return filepath.Join(xdg, ConfigDirName, "config.json")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return AppName + "-config.json"
|
|
}
|
|
return filepath.Join(home, ".config", ConfigDirName, "config.json")
|
|
}
|
|
|
|
// Load reads the config at path. Missing file returns a default config anchored to path.
|
|
func Load(path string) (*Config, error) {
|
|
if path == "" {
|
|
path = DefaultPath()
|
|
}
|
|
c := Default()
|
|
c.path = path
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return c, nil
|
|
}
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
if len(data) == 0 {
|
|
return c, nil
|
|
}
|
|
if err := json.Unmarshal(data, c); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
if c.Server.Host == "" {
|
|
c.Server.Host = "127.0.0.1"
|
|
}
|
|
if c.Server.Port == 0 {
|
|
c.Server.Port = 9982
|
|
}
|
|
if c.Server.APIKey == "" {
|
|
c.Server.APIKey = DefaultLocalAPIKey
|
|
}
|
|
c.path = path
|
|
return c, nil
|
|
}
|
|
|
|
// Path returns the on-disk path of this config.
|
|
func (c *Config) Path() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.path
|
|
}
|
|
|
|
// Save writes config atomically.
|
|
func (c *Config) Save() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.path == "" {
|
|
c.path = DefaultPath()
|
|
}
|
|
path := c.path
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
return fileutil.WithLockedFile(path, func() error {
|
|
providers := cloneProviders(c.Providers)
|
|
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
|
aliases := cloneAliases(c.Aliases)
|
|
sort.Slice(aliases, func(i, j int) bool { return aliases[i].Alias < aliases[j].Alias })
|
|
snap := struct {
|
|
Server Server `json:"server"`
|
|
Providers []Provider `json:"providers"`
|
|
Aliases []Alias `json:"aliases"`
|
|
}{c.Server, providers, aliases}
|
|
data, err := json.MarshalIndent(snap, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
return fileutil.AtomicWriteFile(path, data, 0o600)
|
|
})
|
|
}
|
|
|
|
// FindProvider returns the provider with matching id or nil.
|
|
func (c *Config) FindProvider(id string) *Provider {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
p := c.findProviderLocked(id)
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
clone := cloneProvider(*p)
|
|
return &clone
|
|
}
|
|
|
|
func (c *Config) findProviderLocked(id string) *Provider {
|
|
for i := range c.Providers {
|
|
if c.Providers[i].ID == id {
|
|
return &c.Providers[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertProvider adds or replaces a provider by id.
|
|
func (c *Config) UpsertProvider(p Provider) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
cloned := cloneProvider(p)
|
|
for i := range c.Providers {
|
|
if c.Providers[i].ID == p.ID {
|
|
c.Providers[i] = cloned
|
|
return
|
|
}
|
|
}
|
|
c.Providers = append(c.Providers, cloned)
|
|
}
|
|
|
|
// RemoveProvider deletes a provider and returns true if removed.
|
|
func (c *Config) RemoveProvider(id string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for i := range c.Providers {
|
|
if c.Providers[i].ID == id {
|
|
c.Providers = append(c.Providers[:i], c.Providers[i+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FindAlias returns the alias record with matching name or nil.
|
|
func (c *Config) FindAlias(name string) *Alias {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
for i := range c.Aliases {
|
|
if c.Aliases[i].Alias == name {
|
|
clone := cloneAlias(c.Aliases[i])
|
|
return &clone
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertAlias adds or replaces an alias.
|
|
func (c *Config) UpsertAlias(a Alias) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
cloned := cloneAlias(a)
|
|
for i := range c.Aliases {
|
|
if c.Aliases[i].Alias == a.Alias {
|
|
c.Aliases[i] = cloned
|
|
return
|
|
}
|
|
}
|
|
c.Aliases = append(c.Aliases, cloned)
|
|
}
|
|
|
|
// RemoveAlias deletes an alias.
|
|
func (c *Config) RemoveAlias(name string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for i := range c.Aliases {
|
|
if c.Aliases[i].Alias == name {
|
|
c.Aliases = append(c.Aliases[:i], c.Aliases[i+1:]...)
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AddTarget appends a target to an alias; creates alias if missing.
|
|
func (c *Config) AddTarget(alias string, t Target) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for i := range c.Aliases {
|
|
if c.Aliases[i].Alias == alias {
|
|
// prevent exact duplicate
|
|
for _, x := range c.Aliases[i].Targets {
|
|
if x.Provider == t.Provider && x.Model == t.Model {
|
|
return fmt.Errorf("target %s/%s already bound to alias %s", t.Provider, t.Model, alias)
|
|
}
|
|
}
|
|
c.Aliases[i].Targets = append(c.Aliases[i].Targets, t)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("alias %q not found", alias)
|
|
}
|
|
|
|
// RemoveTarget removes a target by provider+model.
|
|
func (c *Config) RemoveTarget(alias, provider, model string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for i := range c.Aliases {
|
|
if c.Aliases[i].Alias != alias {
|
|
continue
|
|
}
|
|
out := c.Aliases[i].Targets[:0]
|
|
found := false
|
|
for _, t := range c.Aliases[i].Targets {
|
|
if t.Provider == provider && t.Model == model {
|
|
found = true
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("target %s/%s not found on alias %s", provider, model, alias)
|
|
}
|
|
c.Aliases[i].Targets = out
|
|
return nil
|
|
}
|
|
return fmt.Errorf("alias %q not found", alias)
|
|
}
|
|
|
|
// AvailableTargets returns alias targets that are individually enabled and point
|
|
// at providers that still exist and are enabled.
|
|
func (c *Config) AvailableTargets(alias Alias) []Target {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.availableTargetsLocked(alias)
|
|
}
|
|
|
|
func (c *Config) availableTargetsLocked(alias Alias) []Target {
|
|
targets := make([]Target, 0, len(alias.Targets))
|
|
for _, t := range alias.Targets {
|
|
if !t.Enabled {
|
|
continue
|
|
}
|
|
provider := c.findProviderLocked(t.Provider)
|
|
if provider == nil || !provider.IsEnabled() {
|
|
continue
|
|
}
|
|
targets = append(targets, t)
|
|
}
|
|
return targets
|
|
}
|
|
|
|
// AvailableAliasNames returns alias names that are enabled and still have at
|
|
// least one routable target after provider availability is applied.
|
|
func (c *Config) AvailableAliasNames() []string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
names := make([]string, 0, len(c.Aliases))
|
|
for _, a := range c.Aliases {
|
|
if !a.Enabled {
|
|
continue
|
|
}
|
|
if len(c.availableTargetsLocked(a)) == 0 {
|
|
continue
|
|
}
|
|
names = append(names, a.Alias)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// Validate returns a non-nil error slice for every structural issue found.
|
|
func (c *Config) Validate() []error {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
var errs []error
|
|
ids := map[string]bool{}
|
|
for _, p := range c.Providers {
|
|
if p.ID == "" {
|
|
errs = append(errs, fmt.Errorf("provider with empty id"))
|
|
continue
|
|
}
|
|
if ids[p.ID] {
|
|
errs = append(errs, fmt.Errorf("duplicate provider id %q", p.ID))
|
|
}
|
|
ids[p.ID] = true
|
|
if p.BaseURL == "" {
|
|
errs = append(errs, fmt.Errorf("provider %q missing base_url", p.ID))
|
|
continue
|
|
}
|
|
if err := ValidateProviderBaseURL(p.BaseURL); err != nil {
|
|
errs = append(errs, fmt.Errorf("provider %q %s", p.ID, err))
|
|
}
|
|
seenModels := map[string]bool{}
|
|
for _, model := range p.Models {
|
|
trimmed := strings.TrimSpace(model)
|
|
if trimmed == "" {
|
|
errs = append(errs, fmt.Errorf("provider %q has empty model entry", p.ID))
|
|
continue
|
|
}
|
|
if seenModels[trimmed] {
|
|
errs = append(errs, fmt.Errorf("provider %q has duplicate model %q", p.ID, trimmed))
|
|
continue
|
|
}
|
|
seenModels[trimmed] = true
|
|
}
|
|
validModelsSource := p.ModelsSource == "" || p.ModelsSource == "discovered" || p.ModelsSource == "imported"
|
|
if !validModelsSource {
|
|
errs = append(errs, fmt.Errorf("provider %q has invalid models_source %q", p.ID, p.ModelsSource))
|
|
}
|
|
if validModelsSource && p.ModelsSource != "" && len(p.Models) == 0 {
|
|
errs = append(errs, fmt.Errorf("provider %q has models_source %q but no models", p.ID, p.ModelsSource))
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, a := range c.Aliases {
|
|
if a.Alias == "" {
|
|
errs = append(errs, fmt.Errorf("alias with empty name"))
|
|
continue
|
|
}
|
|
if seen[a.Alias] {
|
|
errs = append(errs, fmt.Errorf("duplicate alias %q", a.Alias))
|
|
}
|
|
seen[a.Alias] = true
|
|
enabled := 0
|
|
for _, t := range a.Targets {
|
|
if t.Provider == "" || t.Model == "" {
|
|
errs = append(errs, fmt.Errorf("alias %q has malformed target", a.Alias))
|
|
continue
|
|
}
|
|
if !ids[t.Provider] {
|
|
errs = append(errs, fmt.Errorf("alias %q references unknown provider %q", a.Alias, t.Provider))
|
|
}
|
|
}
|
|
enabled = len(c.availableTargetsLocked(a))
|
|
if a.Enabled && enabled == 0 {
|
|
errs = append(errs, fmt.Errorf("alias %q has no available targets", a.Alias))
|
|
}
|
|
}
|
|
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
|
errs = append(errs, fmt.Errorf("invalid server port %d", c.Server.Port))
|
|
}
|
|
if c.Server.APIKey == DefaultLocalAPIKey && !isLoopbackHost(c.Server.Host) {
|
|
errs = append(errs, fmt.Errorf("server.api_key must not use the default value when listening on non-loopback host %q", c.Server.Host))
|
|
}
|
|
return errs
|
|
}
|
|
func isLoopbackHost(host string) bool {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" || strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
func cloneProvider(p Provider) Provider {
|
|
p.Headers = cloneStringMap(p.Headers)
|
|
p.Models = cloneStrings(p.Models)
|
|
return p
|
|
}
|
|
|
|
func cloneAlias(a Alias) Alias {
|
|
a.Targets = cloneTargets(a.Targets)
|
|
return a
|
|
}
|
|
|
|
func cloneProviders(in []Provider) []Provider {
|
|
out := make([]Provider, len(in))
|
|
for i := range in {
|
|
out[i] = cloneProvider(in[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneAliases(in []Alias) []Alias {
|
|
out := make([]Alias, len(in))
|
|
for i := range in {
|
|
out[i] = cloneAlias(in[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneTargets(in []Target) []Target {
|
|
out := make([]Target, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
func cloneStrings(in []string) []string {
|
|
out := make([]string, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|