opencode-provider-switch/internal/config/config.go

341 lines
8.4 KiB
Go

// Package config manages the local ops JSON config file.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// 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"`
}
// 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 ops config.
type Config struct {
Server Server `json:"server"`
Providers []Provider `json:"providers"`
Aliases []Alias `json:"aliases"`
path string
mu sync.RWMutex
}
// ValidateProviderBaseURL checks the MVP requirement that upstream base URLs
// point at an OpenAI-compatible /v1 root.
func ValidateProviderBaseURL(baseURL string) error {
trimmed := strings.TrimRight(strings.TrimSpace(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: "ops-local",
},
Providers: []Provider{},
Aliases: []Alias{},
}
}
// DefaultPath returns ~/.config/ops/config.json (XDG aware).
func DefaultPath() string {
if p := os.Getenv("OPS_CONFIG"); p != "" {
return p
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "ops", "config.json")
}
home, err := os.UserHomeDir()
if err != nil {
return "ops-config.json"
}
return filepath.Join(home, ".config", "ops", "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 = "ops-local"
}
c.path = path
return c, nil
}
// Path returns the on-disk path of this config.
func (c *Config) Path() string { return c.path }
// Save writes config atomically.
func (c *Config) Save() error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.path == "" {
c.path = DefaultPath()
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
// sort for stability
providers := append([]Provider(nil), c.Providers...)
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
aliases := append([]Alias(nil), 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')
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write tmp: %w", err)
}
return os.Rename(tmp, c.path)
}
// FindProvider returns the provider with matching id or nil.
func (c *Config) FindProvider(id string) *Provider {
c.mu.RLock()
defer c.mu.RUnlock()
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()
for i := range c.Providers {
if c.Providers[i].ID == p.ID {
c.Providers[i] = p
return
}
}
c.Providers = append(c.Providers, p)
}
// 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 {
return &c.Aliases[i]
}
}
return nil
}
// UpsertAlias adds or replaces an alias.
func (c *Config) UpsertAlias(a Alias) {
c.mu.Lock()
defer c.mu.Unlock()
for i := range c.Aliases {
if c.Aliases[i].Alias == a.Alias {
c.Aliases[i] = a
return
}
}
c.Aliases = append(c.Aliases, a)
}
// 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)
}
// 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))
}
}
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))
}
if t.Enabled {
enabled++
}
}
if a.Enabled && enabled == 0 {
errs = append(errs, fmt.Errorf("alias %q has no enabled targets", a.Alias))
}
}
if c.Server.Port <= 0 || c.Server.Port > 65535 {
errs = append(errs, fmt.Errorf("invalid server port %d", c.Server.Port))
}
return errs
}