// 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, "", " ") }