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

+396
View File
@@ -0,0 +1,396 @@
package gateway
import (
"bufio"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// fakeUpstream mimics a Chat Completions provider. It records the last
// request so tests can assert on the converted model and messages.
type fakeUpstream struct {
lastModel string
lastAuth string
lastRequest map[string]any
}
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
w.WriteHeader(http.StatusNotFound)
return
}
f.lastAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &f.lastRequest)
f.lastModel, _ = f.lastRequest["model"].(string)
stream, _ := f.lastRequest["stream"].(bool)
if stream {
w.Header().Set("Content-Type", "text/event-stream")
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hi"}}]}`)
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":" there"}}]}`)
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":2}}`)
writeSSELine(w, "data: [DONE]")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-1",
"object": "chat.completion",
"model": f.lastModel,
"choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": "Hi there"}, "finish_reason": "stop"}},
"usage": map[string]any{"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11},
})
}
func writeSSELine(w http.ResponseWriter, line string) {
_, _ = io.WriteString(w, line+"\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
type testEnv struct {
gateway *httptest.Server
upstream *fakeUpstream
store *config.Store
settings string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
t.Setenv("CROSSRELAY_HOME", t.TempDir())
settingsPath := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(settingsPath, []byte(`{"theme":"dark","env":{"DISABLE_AUTOUPDATER":"1"}}`), 0o600); err != nil {
t.Fatalf("write settings: %v", err)
}
t.Setenv("CROSSRELAY_CLAUDE_SETTINGS", settingsPath)
upstream := &fakeUpstream{}
up := httptest.NewServer(upstream)
t.Cleanup(up.Close)
store, err := config.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
p, err := store.AddProvider("test", "", up.URL, "up-key", config.Models{
Default: "ds-chat",
Sonnet: "ds-chat",
Haiku: "ds-haiku",
Opus: "ds-opus",
})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateClaude(p.ID); err != nil {
t.Fatalf("ActivateClaude: %v", err)
}
gw := httptest.NewServer(New(store, 18787).Handler())
t.Cleanup(gw.Close)
return &testEnv{gateway: gw, upstream: upstream, store: store, settings: settingsPath}
}
func (e *testEnv) post(t *testing.T, path string, body any, token string) *http.Response {
t.Helper()
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
reader = strings.NewReader(string(data))
}
req, err := http.NewRequest(http.MethodPost, e.gateway.URL+path, reader)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post %s: %v", path, err)
}
return resp
}
func messagesBody(model string, stream bool) map[string]any {
return map[string]any{
"model": model,
"max_tokens": 100,
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
"stream": stream,
}
}
func TestMessagesNonStream(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out["type"] != "message" || out["role"] != "assistant" {
t.Errorf("envelope = %v", out)
}
if out["model"] != "claude-sonnet-4-5" {
t.Errorf("model = %v, want requested model echoed", out["model"])
}
content := out["content"].([]any)
if len(content) != 1 {
t.Fatalf("content = %v", content)
}
block := content[0].(map[string]any)
if block["type"] != "text" || block["text"] != "Hi there" {
t.Errorf("text block = %v", block)
}
if out["stop_reason"] != "end_turn" {
t.Errorf("stop reason = %v", out["stop_reason"])
}
usage := out["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
t.Errorf("usage = %v", usage)
}
// The upstream must have seen the mapped model and the provider key.
if env.upstream.lastModel != "ds-chat" {
t.Errorf("upstream model = %q, want ds-chat", env.upstream.lastModel)
}
if env.upstream.lastAuth != "Bearer up-key" {
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
}
}
func TestMessagesHaikuRoleMapping(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-3-5-haiku-20241022", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if env.upstream.lastModel != "ds-haiku" {
t.Errorf("upstream model = %q, want ds-haiku", env.upstream.lastModel)
}
}
func TestMessagesRequiresToken(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("m", false), "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
errObj := out["error"].(map[string]any)
if errObj["type"] != "authentication_error" {
t.Errorf("error = %v", errObj)
}
}
func TestMessagesWithoutActiveProvider(t *testing.T) {
env := newTestEnv(t)
if err := env.store.ClearActiveClaude(); err != nil {
t.Fatalf("ClearActiveClaude: %v", err)
}
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestMessagesStream(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", true), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("content type = %q", ct)
}
scanner := bufio.NewScanner(resp.Body)
var events []string
var text strings.Builder
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
events = append(events, strings.TrimPrefix(line, "event: "))
}
if strings.HasPrefix(line, "data: ") && strings.Contains(line, "text_delta") {
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
delta := payload["delta"].(map[string]any)
text.WriteString(delta["text"].(string))
}
}
}
want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop"}
if len(events) != len(want) {
t.Fatalf("events = %v, want %v", events, want)
}
for i := range want {
if events[i] != want[i] {
t.Fatalf("events = %v, want %v", events, want)
}
}
if text.String() != "Hi there" {
t.Errorf("streamed text = %q", text.String())
}
}
func TestCountTokens(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages/count_tokens", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
if out["input_tokens"].(float64) <= 0 {
t.Errorf("input tokens = %v", out["input_tokens"])
}
}
func TestApplyAndRestore(t *testing.T) {
env := newTestEnv(t)
providers := env.store.Config().Providers
if len(providers) != 1 {
t.Fatalf("providers = %d", len(providers))
}
resp := env.post(t, "/api/providers/"+providers[0].ID+"/apply", nil, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("apply status = %d", resp.StatusCode)
}
data, err := os.ReadFile(env.settings)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var settings map[string]any
_ = json.Unmarshal(data, &settings)
if settings["theme"] != "dark" {
t.Errorf("theme lost: %v", settings)
}
envVars := settings["env"].(map[string]any)
if envVars["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:18787" {
t.Errorf("base url = %v", envVars["ANTHROPIC_BASE_URL"])
}
if envVars["ANTHROPIC_AUTH_TOKEN"] != env.store.Config().RelayToken {
t.Errorf("relay token = %v", envVars["ANTHROPIC_AUTH_TOKEN"])
}
if envVars["DISABLE_AUTOUPDATER"] != "1" {
t.Errorf("unrelated env key lost: %v", envVars)
}
resp = env.post(t, "/api/restore", nil, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("restore status = %d", resp.StatusCode)
}
data, _ = os.ReadFile(env.settings)
_ = json.Unmarshal(data, &settings)
envVars = settings["env"].(map[string]any)
if _, ok := envVars["ANTHROPIC_BASE_URL"]; ok {
t.Error("base url survived restore")
}
if env.store.ActiveClaude() != nil {
t.Error("active provider survived restore")
}
}
func TestStatusAndLogs(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
get := func(path string) map[string]any {
resp, err := http.Get(env.gateway.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return out
}
status := get("/api/status")
if status["running"] != true || status["port"].(float64) != 18787 {
t.Errorf("status = %v", status)
}
stats := status["stats"].(map[string]any)
if stats["requests_total"].(float64) < 1 {
t.Errorf("stats = %v", stats)
}
active := status["active_claude_provider"].(map[string]any)
if active["name"] != "test" {
t.Errorf("active provider = %v", active)
}
logs := get("/api/logs")
items := logs["items"].([]any)
var messageLog map[string]any
for _, item := range items {
entry := item.(map[string]any)
if entry["path"] == "/v1/messages" {
messageLog = entry
break
}
}
if messageLog == nil {
t.Fatalf("no /v1/messages entry in %v", items)
}
if messageLog["model"] != "ds-chat" {
t.Errorf("log model = %v, want the resolved model", messageLog["model"])
}
if messageLog["status"].(float64) != 200 {
t.Errorf("log status = %v", messageLog["status"])
}
}
func TestProviderCRUD(t *testing.T) {
env := newTestEnv(t)
post := func(path string, body any) *http.Response {
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, env.gateway.URL+path, strings.NewReader(string(data)))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
resp := post("/api/providers", map[string]any{
"name": "kimi",
"base_url": "https://api.moonshot.cn/v1",
"api_key": "sk-kimi",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d", resp.StatusCode)
}
if len(env.store.Config().Providers) != 2 {
t.Fatalf("providers = %d, want 2", len(env.store.Config().Providers))
}
resp = post("/api/providers", map[string]any{"name": "", "base_url": "x", "api_key": "y"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("invalid create status = %d, want 400", resp.StatusCode)
}
}