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,249 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Store loads and persists the configuration file. All methods are safe
|
||||
// for concurrent use.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
// Dir returns the CrossRelay data directory. CROSSRELAY_HOME overrides
|
||||
// the default location (~/.crossrelay), which tests rely on.
|
||||
func Dir() string {
|
||||
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_HOME")); v != "" {
|
||||
return v
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".crossrelay"
|
||||
}
|
||||
return filepath.Join(home, ".crossrelay")
|
||||
}
|
||||
|
||||
// Path returns the config file location.
|
||||
func Path() string { return filepath.Join(Dir(), "config.json") }
|
||||
|
||||
// Load reads the config file, creating defaults when it does not exist.
|
||||
func Load() (*Store, error) {
|
||||
s := &Store{path: Path()}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
s.cfg = DefaultConfig()
|
||||
return s, s.save()
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
cfg.normalize()
|
||||
s.cfg = &cfg
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Config returns a snapshot of the configuration.
|
||||
func (s *Store) Config() *Config {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.cfg.clone()
|
||||
}
|
||||
|
||||
// FindProvider returns a copy of the provider with the given id.
|
||||
func (s *Store) FindProvider(id string) *Provider {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.cfg.Providers {
|
||||
if s.cfg.Providers[i].ID == id {
|
||||
return s.cfg.Providers[i].clone()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActiveClaude returns the provider currently applied to Claude Code.
|
||||
func (s *Store) ActiveClaude() *Provider {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cfg.ActiveClaudeID == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range s.cfg.Providers {
|
||||
if s.cfg.Providers[i].ID == s.cfg.ActiveClaudeID {
|
||||
return s.cfg.Providers[i].clone()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProvider validates and appends a provider.
|
||||
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, err := newProvider(name, preset, baseURL, apiKey, models)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cfg.Providers = append(s.cfg.Providers, *p)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.clone(), nil
|
||||
}
|
||||
|
||||
// UpdateProvider replaces a provider's editable fields. An empty apiKey
|
||||
// keeps the stored key so edits never wipe it by accident.
|
||||
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.cfg.Providers {
|
||||
if s.cfg.Providers[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
existing := s.cfg.Providers[idx]
|
||||
if apiKey == "" {
|
||||
apiKey = existing.APIKey
|
||||
}
|
||||
updated, err := newProvider(name, preset, baseURL, apiKey, models)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated.ID = existing.ID
|
||||
updated.EnabledForClaude = existing.EnabledForClaude
|
||||
updated.CreatedAt = existing.CreatedAt
|
||||
s.cfg.Providers[idx] = *updated
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updated.clone(), nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes a provider and clears it when active.
|
||||
func (s *Store) DeleteProvider(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.cfg.Providers {
|
||||
if s.cfg.Providers[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
s.cfg.Providers = append(s.cfg.Providers[:idx], s.cfg.Providers[idx+1:]...)
|
||||
if s.cfg.ActiveClaudeID == id {
|
||||
s.cfg.ActiveClaudeID = ""
|
||||
}
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// ActivateClaude marks a provider as the one written into Claude Code's
|
||||
// settings and records it as active.
|
||||
func (s *Store) ActivateClaude(id string) (*Provider, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
idx := -1
|
||||
for i := range s.cfg.Providers {
|
||||
if s.cfg.Providers[i].ID == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("provider %q not found", id)
|
||||
}
|
||||
s.cfg.Providers[idx].EnabledForClaude = true
|
||||
s.cfg.ActiveClaudeID = id
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.cfg.Providers[idx].clone(), nil
|
||||
}
|
||||
|
||||
// ClearActiveClaude forgets the active provider without touching the
|
||||
// provider list (used after restoring the official settings).
|
||||
func (s *Store) ClearActiveClaude() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cfg.ActiveClaudeID == "" {
|
||||
return nil
|
||||
}
|
||||
s.cfg.ActiveClaudeID = ""
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// SetGateway updates listener settings.
|
||||
func (s *Store) SetGateway(g Gateway) error {
|
||||
g.PortMode = strings.TrimSpace(g.PortMode)
|
||||
switch g.PortMode {
|
||||
case PortModeAuto:
|
||||
g.FixedPort = 0
|
||||
case PortModeFixed:
|
||||
if g.FixedPort < 1 || g.FixedPort > 65535 {
|
||||
return errors.New("fixed port must be between 1 and 65535")
|
||||
}
|
||||
default:
|
||||
return errors.New("port mode must be \"auto\" or \"fixed\"")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cfg.Gateway = g
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// SetLanguage updates the UI language.
|
||||
func (s *Store) SetLanguage(lang string) error {
|
||||
if lang != LanguageChinese && lang != LanguageEnglish {
|
||||
return errors.New("language must be zh-CN or en-US")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cfg.Language = lang
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) save() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
data, err := marshalConfig(s.cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user