270 lines
7.2 KiB
Go
270 lines
7.2 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.
|
|
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, "", " ")
|
|
}
|