opencode-provider-switch/internal/config/config.go
apale7 5c4f60f2b7 feat(desktop): finish Windows GUI integration
Bring the Wails desktop shell to feature parity with the CLI for provider and alias management while keeping the browser fallback working. Wire tray, notifications, autostart, and GUI warnings so desktop flows behave predictably.
2026-04-19 01:02:16 +08:00

509 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"`
}
// Desktop holds desktop-shell user preferences.
type Desktop struct {
LaunchAtLogin bool `json:"launch_at_login,omitempty"`
MinimizeToTray bool `json:"minimize_to_tray,omitempty"`
Notifications bool `json:"notifications,omitempty"`
}
// Config is the on-disk ocswitch config.
type Config struct {
Server Server `json:"server"`
Desktop Desktop `json:"desktop,omitempty"`
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,
},
Desktop: Desktop{},
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"`
Desktop Desktop `json:"desktop,omitempty"`
Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"`
}{c.Server, c.Desktop, 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
}