Files
CrossRelay/internal/config/config.go
T

275 lines
7.3 KiB
Go

// Package config owns CrossRelay's own persisted state: the provider list,
// the active selections, local gateway settings and UI preferences.
package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// Version is the config.json schema version.
const Version = 1
// Listener modes for the local gateway.
const (
PortModeAuto = "auto"
PortModeFixed = "fixed"
)
// Supported UI languages.
const (
LanguageChinese = "zh-CN"
LanguageEnglish = "en-US"
)
// Client targets. The Claude and Codex pages each manage their own
// isolated set of providers.
const (
TargetClaude = "claude"
TargetCodex = "codex"
)
// Models maps requested model names onto upstream model names. The role
// fields are matched by keyword (a request mentioning "haiku" uses Haiku);
// Extra holds exact requested-model overrides and wins over everything.
type Models struct {
Default string `json:"default,omitempty"`
Sonnet string `json:"sonnet,omitempty"`
Haiku string `json:"haiku,omitempty"`
Opus string `json:"opus,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// Provider is one upstream Chat Completions endpoint.
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
Preset string `json:"preset,omitempty"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models Models `json:"models"`
EnabledForClaude bool `json:"enabled_for_claude"`
EnabledForCodex bool `json:"enabled_for_codex"`
Target string `json:"target,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Gateway holds local listener settings.
type Gateway struct {
PortMode string `json:"port_mode"`
FixedPort int `json:"fixed_port,omitempty"`
}
// Config is the whole persisted configuration.
type Config struct {
Version int `json:"version"`
RelayToken string `json:"relay_token"`
Providers []Provider `json:"providers"`
ActiveClaudeID string `json:"active_claude_id,omitempty"`
ActiveCodexID string `json:"active_codex_id,omitempty"`
// CodexPriorProvider remembers the model_provider value that was in
// config.toml before CrossRelay first applied, so restore can put it
// back instead of dropping the key.
CodexPriorProvider string `json:"codex_prior_provider,omitempty"`
Gateway Gateway `json:"gateway"`
Language string `json:"language"`
}
// ResolveModel maps the model name Claude Code asked for onto the model
// name the upstream provider should receive.
func (p *Provider) ResolveModel(requested string) string {
if p == nil {
return requested
}
if requested == "" {
return firstNonEmpty(p.Models.Default, requested)
}
// Already an upstream model name (CrossRelay wrote it into the env).
for _, m := range []string{p.Models.Default, p.Models.Sonnet, p.Models.Haiku, p.Models.Opus} {
if m != "" && m == requested {
return requested
}
}
if m, ok := p.Models.Extra[requested]; ok && m != "" {
return m
}
lower := strings.ToLower(requested)
switch {
case strings.Contains(lower, "haiku"):
return firstNonEmpty(p.Models.Haiku, p.Models.Default, requested)
case strings.Contains(lower, "opus"):
return firstNonEmpty(p.Models.Opus, p.Models.Default, requested)
case strings.Contains(lower, "sonnet"):
return firstNonEmpty(p.Models.Sonnet, p.Models.Default, requested)
}
return firstNonEmpty(p.Models.Default, requested)
}
func (p *Provider) clone() *Provider {
if p == nil {
return nil
}
out := *p
if p.Models.Extra != nil {
out.Models.Extra = make(map[string]string, len(p.Models.Extra))
for k, v := range p.Models.Extra {
out.Models.Extra[k] = v
}
}
return &out
}
func (m *Models) normalize() {
m.Default = strings.TrimSpace(m.Default)
m.Sonnet = strings.TrimSpace(m.Sonnet)
m.Haiku = strings.TrimSpace(m.Haiku)
m.Opus = strings.TrimSpace(m.Opus)
for k, v := range m.Extra {
key := strings.TrimSpace(k)
val := strings.TrimSpace(v)
if key == "" || val == "" {
delete(m.Extra, k)
continue
}
if key != k || val != v {
delete(m.Extra, k)
m.Extra[key] = val
}
}
if len(m.Extra) == 0 {
m.Extra = nil
}
}
// DefaultConfig returns a fresh configuration with a new relay token.
func DefaultConfig() *Config {
return &Config{
Version: Version,
RelayToken: NewRelayToken(),
Providers: []Provider{},
Gateway: Gateway{PortMode: PortModeAuto},
Language: LanguageChinese,
}
}
func (c *Config) normalize() {
if c.Version == 0 {
c.Version = Version
}
if strings.TrimSpace(c.RelayToken) == "" {
c.RelayToken = NewRelayToken()
}
if c.Providers == nil {
c.Providers = []Provider{}
}
for i := range c.Providers {
c.Providers[i].Models.normalize()
switch c.Providers[i].Target {
case TargetClaude, TargetCodex:
default:
// Providers created before targets existed belong to the
// Claude page, unless they were only ever activated for
// Codex.
if c.Providers[i].EnabledForCodex && !c.Providers[i].EnabledForClaude {
c.Providers[i].Target = TargetCodex
} else {
c.Providers[i].Target = TargetClaude
}
}
}
switch c.Gateway.PortMode {
case PortModeAuto, PortModeFixed:
default:
c.Gateway.PortMode = PortModeAuto
}
if c.Gateway.PortMode == PortModeFixed && (c.Gateway.FixedPort < 1 || c.Gateway.FixedPort > 65535) {
c.Gateway.PortMode = PortModeAuto
c.Gateway.FixedPort = 0
}
if c.Language != LanguageChinese && c.Language != LanguageEnglish {
c.Language = LanguageChinese
}
}
func (c *Config) clone() *Config {
out := *c
out.Providers = make([]Provider, len(c.Providers))
for i, p := range c.Providers {
out.Providers[i] = *p.clone()
}
return &out
}
func newProvider(name, preset, baseURL, apiKey string, models Models, target string) (*Provider, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name is required")
}
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return nil, errors.New("base URL is required")
}
u, err := url.Parse(baseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("base URL must be an http(s) URL: %q", baseURL)
}
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, errors.New("API key is required")
}
models.normalize()
switch target {
case TargetClaude, TargetCodex:
case "":
target = TargetClaude
default:
return nil, fmt.Errorf("target must be %q or %q", TargetClaude, TargetCodex)
}
return &Provider{
ID: newID("p_"),
Name: name,
Preset: strings.TrimSpace(preset),
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Models: models,
Target: target,
CreatedAt: time.Now().UTC(),
}, nil
}
// NewRelayToken generates the local token Claude Code must present.
func NewRelayToken() string {
return "crl_" + randHex(24)
}
func newID(prefix string) string {
return prefix + randHex(12)
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func marshalConfig(c *Config) ([]byte, error) {
return json.MarshalIndent(c, "", " ")
}