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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user