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:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+240
View File
@@ -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, "", " ")
}
+146
View File
@@ -0,0 +1,146 @@
package config
import (
"strings"
"testing"
)
func TestResolveModel(t *testing.T) {
p := &Provider{
Models: Models{
Default: "ds-chat",
Sonnet: "ds-chat",
Haiku: "ds-haiku",
Opus: "ds-opus",
Extra: map[string]string{"claude-3-5-haiku-20241022": "ds-fast"},
},
}
cases := []struct {
requested string
want string
}{
{"claude-sonnet-4-5-20250929", "ds-chat"},
{"claude-3-5-haiku-20241022", "ds-fast"}, // exact Extra match wins
{"some-haiku-model", "ds-haiku"}, // keyword match
{"claude-opus-4-1", "ds-opus"},
{"ds-chat", "ds-chat"}, // already an upstream name
{"unknown-model", "ds-chat"},
{"", "ds-chat"},
}
for _, tc := range cases {
if got := p.ResolveModel(tc.requested); got != tc.want {
t.Errorf("ResolveModel(%q) = %q, want %q", tc.requested, got, tc.want)
}
}
}
func TestResolveModelWithoutDefaults(t *testing.T) {
p := &Provider{Models: Models{Sonnet: "kimi-k2"}}
if got := p.ResolveModel("claude-3-7-sonnet"); got != "kimi-k2" {
t.Errorf("sonnet request = %q, want kimi-k2", got)
}
// No default configured: unknown models pass through unchanged.
if got := p.ResolveModel("weird-model"); got != "weird-model" {
t.Errorf("unknown request = %q, want passthrough", got)
}
}
func TestStoreRoundTrip(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !strings.HasPrefix(store.Config().RelayToken, "crl_") {
t.Fatalf("relay token = %q, want crl_ prefix", store.Config().RelayToken)
}
if store.Config().Language != LanguageChinese {
t.Fatalf("default language = %q", store.Config().Language)
}
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat", Sonnet: "deepseek-chat", Haiku: "deepseek-chat", Opus: "deepseek-chat"})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateClaude(p.ID); err != nil {
t.Fatalf("ActivateClaude: %v", err)
}
reloaded, err := Load()
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := reloaded.ActiveClaude(); got == nil || got.Name != "DeepSeek" {
t.Fatalf("active provider after reload = %+v", got)
}
if len(reloaded.Config().Providers) != 1 {
t.Fatalf("providers after reload = %d", len(reloaded.Config().Providers))
}
}
func TestAddProviderValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
cases := []struct {
name, baseURL, apiKey string
}{
{"", "https://x.com/v1", "sk"},
{"p", "", "sk"},
{"p", "ftp://x.com", "sk"},
{"p", "https://x.com/v1", ""},
}
for _, tc := range cases {
if _, err := store.AddProvider(tc.name, "", tc.baseURL, tc.apiKey, Models{}); err == nil {
t.Errorf("AddProvider(%q, %q, %q) succeeded, want error", tc.name, tc.baseURL, tc.apiKey)
}
}
}
func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"})
updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"})
if err != nil {
t.Fatalf("UpdateProvider: %v", err)
}
if updated.APIKey != "sk-secret" {
t.Errorf("api key = %q, want sk-secret", updated.APIKey)
}
if updated.Name != "p2" || updated.Models.Default != "m2" {
t.Errorf("update lost fields: %+v", updated)
}
if updated.BaseURL != "https://x.com/v1" {
t.Errorf("base url = %q, want trailing slash trimmed", updated.BaseURL)
}
}
func TestDeleteProviderClearsActive(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{})
store.ActivateClaude(p.ID)
if err := store.DeleteProvider(p.ID); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if store.ActiveClaude() != nil {
t.Error("active provider survived deletion")
}
}
func TestSetGatewayValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 8787}); err != nil {
t.Fatalf("SetGateway: %v", err)
}
if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 0}); err == nil {
t.Error("fixed port 0 accepted")
}
if err := store.SetGateway(Gateway{PortMode: "bogus"}); err == nil {
t.Error("bogus port mode accepted")
}
if err := store.SetLanguage("fr"); err == nil {
t.Error("unknown language accepted")
}
}
+249
View File
@@ -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
}