CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
@@ -0,0 +1,240 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
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.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) (*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()
|
||||
return &Provider{
|
||||
ID: newID("p_"),
|
||||
Name: name,
|
||||
Preset: strings.TrimSpace(preset),
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
Models: models,
|
||||
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, "", " ")
|
||||
}
|
||||
Reference in new issue
Block a user