Align the binary, config, provider, proxy, tests, and docs with the OpenCode LocalProxy CLI rename so user-facing guidance and runtime identifiers stay consistent.
341 lines
8.4 KiB
Go
341 lines
8.4 KiB
Go
// Package config manages the local olpx 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 olpx 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: "olpx-local",
|
|
},
|
|
Providers: []Provider{},
|
|
Aliases: []Alias{},
|
|
}
|
|
}
|
|
|
|
// DefaultPath returns ~/.config/olpx/config.json (XDG aware).
|
|
func DefaultPath() string {
|
|
if p := os.Getenv("OLPX_CONFIG"); p != "" {
|
|
return p
|
|
}
|
|
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
|
return filepath.Join(xdg, "olpx", "config.json")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "olpx-config.json"
|
|
}
|
|
return filepath.Join(home, ".config", "olpx", "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 = "olpx-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
|
|
}
|