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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
// providerInput is the provider payload accepted by the management API.
|
||||
type providerInput struct {
|
||||
Name string `json:"name"`
|
||||
Preset string `json:"preset"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Models config.Models `json:"models"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.store.Config()
|
||||
var active any
|
||||
if p := s.store.ActiveClaude(); p != nil {
|
||||
active = map[string]any{
|
||||
"id": p.ID,
|
||||
"name": p.Name,
|
||||
"base_url": p.BaseURL,
|
||||
"models": p.Models,
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"running": true,
|
||||
"version": Version,
|
||||
"port": s.port,
|
||||
"uptime_sec": int(time.Since(s.started).Seconds()),
|
||||
"active_claude_provider": active,
|
||||
"stats": s.snapshotStats(),
|
||||
"language": cfg.Language,
|
||||
"port_mode": cfg.Gateway.PortMode,
|
||||
"fixed_port": cfg.Gateway.FixedPort,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.store.Config().Providers})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var in providerInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var in providerInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.store.DeleteProvider(id); err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
// handleApply activates a provider and writes it into Claude Code's
|
||||
// settings.json.
|
||||
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := s.store.ActivateClaude(id)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
mgr, err := s.claudeManager()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings, err := mgr.Apply(claudecfg.ApplyOptions{
|
||||
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", s.port),
|
||||
Token: s.store.Config().RelayToken,
|
||||
Models: p.Models,
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"provider": p,
|
||||
"env": settings.Env,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRestore puts back the most recent backup of settings.json.
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
mgr, err := s.claudeManager()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
name, err := mgr.Restore()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.store.ClearActiveClaude()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"restored_from": name})
|
||||
}
|
||||
|
||||
func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) {
|
||||
mgr, err := s.claudeManager()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items, err := mgr.Backups()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []claudecfg.BackupInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.store.Config()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"language": cfg.Language,
|
||||
"port_mode": cfg.Gateway.PortMode,
|
||||
"fixed_port": cfg.Gateway.FixedPort,
|
||||
})
|
||||
}
|
||||
|
||||
type settingsInput struct {
|
||||
Language string `json:"language"`
|
||||
PortMode string `json:"port_mode"`
|
||||
FixedPort int `json:"fixed_port"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var in settingsInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if in.Language != "" {
|
||||
if err := s.store.SetLanguage(in.Language); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.PortMode != "" {
|
||||
if err := s.store.SetGateway(config.Gateway{
|
||||
PortMode: in.PortMode,
|
||||
FixedPort: in.FixedPort,
|
||||
}); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg := s.store.Config()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"language": cfg.Language,
|
||||
"port_mode": cfg.Gateway.PortMode,
|
||||
"fixed_port": cfg.Gateway.FixedPort,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.logs.list()})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
// logRing keeps the most recent log entries in memory.
|
||||
type logRing struct {
|
||||
mu sync.Mutex
|
||||
entries []LogEntry
|
||||
cap int
|
||||
}
|
||||
|
||||
func newLogRing(capacity int) *logRing {
|
||||
return &logRing{cap: capacity}
|
||||
}
|
||||
|
||||
func (r *logRing) add(entry LogEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries = append(r.entries, entry)
|
||||
if len(r.entries) > r.cap {
|
||||
r.entries = r.entries[len(r.entries)-r.cap:]
|
||||
}
|
||||
}
|
||||
|
||||
// list returns entries newest first.
|
||||
func (r *logRing) list() []LogEntry {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]LogEntry, len(r.entries))
|
||||
for i, e := range r.entries {
|
||||
out[len(r.entries)-1-i] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// handleMessages serves POST /v1/messages in both streaming and
|
||||
// non-streaming modes.
|
||||
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
var req anthropic.MessagesRequest
|
||||
if err := decodeJSON(r, &req, 64<<20); err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
provider := s.store.ActiveClaude()
|
||||
if provider == nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error",
|
||||
"no active provider: activate one in the CrossRelay app")
|
||||
return
|
||||
}
|
||||
model := provider.ResolveModel(req.Model)
|
||||
|
||||
chatReq, err := anthropic.ConvertRequest(&req, model)
|
||||
if err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rec := recordFrom(r)
|
||||
rec.update(func(e *LogEntry) {
|
||||
e.Model = model
|
||||
e.InputTokens = anthropic.EstimateTokens(&req)
|
||||
})
|
||||
|
||||
client := chatcmpl.NewClient(provider.BaseURL, provider.APIKey)
|
||||
if req.Stream {
|
||||
s.handleStream(w, r, client, chatReq, &req, model, rec)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Create(r.Context(), chatReq)
|
||||
if err != nil {
|
||||
s.failUpstream(w, rec, err)
|
||||
return
|
||||
}
|
||||
out := anthropic.ConvertResponse(resp, req.Model)
|
||||
rec.update(func(e *LogEntry) {
|
||||
e.OutputTokens = out.Usage.OutputTokens
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleStream relays a Chat Completions SSE stream as Anthropic events.
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request, client *chatcmpl.Client,
|
||||
chatReq *chatcmpl.Request, req *anthropic.MessagesRequest, model string, rec *record) {
|
||||
|
||||
resp, err := client.CreateStream(r.Context(), chatReq)
|
||||
if err != nil {
|
||||
s.failUpstream(w, rec, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
conv := anthropic.NewStreamConverter(w, req.Model, anthropic.EstimateTokens(req))
|
||||
if err := conv.WriteStart(); err != nil {
|
||||
return
|
||||
}
|
||||
stream := chatcmpl.NewStream(resp.Body)
|
||||
for {
|
||||
chunk, err := stream.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
conv.WriteError("api_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := conv.OnChunk(chunk); err != nil {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
conv.WriteError("api_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := conv.Finish(); err != nil {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
}
|
||||
}
|
||||
|
||||
// handleCountTokens answers POST /v1/messages/count_tokens with a
|
||||
// heuristic estimate so Claude Code's context meter keeps working.
|
||||
func (s *Server) handleCountTokens(w http.ResponseWriter, r *http.Request) {
|
||||
var req anthropic.MessagesRequest
|
||||
if err := decodeJSON(r, &req, 64<<20); err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, anthropic.CountTokensResponse{
|
||||
InputTokens: anthropic.EstimateTokens(&req),
|
||||
})
|
||||
}
|
||||
|
||||
// failUpstream maps an upstream failure onto an Anthropic error.
|
||||
func (s *Server) failUpstream(w http.ResponseWriter, rec *record, err error) {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
var ue *chatcmpl.UpstreamError
|
||||
if errors.As(err, &ue) {
|
||||
switch {
|
||||
case ue.StatusCode == http.StatusUnauthorized || ue.StatusCode == http.StatusForbidden:
|
||||
writeAnthropicError(w, ue.StatusCode, "authentication_error", ue.Message)
|
||||
case ue.StatusCode == http.StatusTooManyRequests:
|
||||
writeAnthropicError(w, ue.StatusCode, "rate_limit_error", ue.Message)
|
||||
case ue.StatusCode == http.StatusBadRequest:
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", ue.Message)
|
||||
default:
|
||||
writeAnthropicError(w, http.StatusBadGateway, "api_error", ue.Message)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeAnthropicError(w, http.StatusBadGateway, "api_error", fmt.Sprintf("upstream request failed: %v", err))
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package gateway serves the Anthropic Messages API to Claude Code and
|
||||
// the local management API to the CrossRelay UI.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
// Version is reported by /api/status.
|
||||
const Version = "0.1.0"
|
||||
|
||||
// Stats aggregates gateway counters.
|
||||
type Stats struct {
|
||||
RequestsTotal int64 `json:"requests_total"`
|
||||
RequestsFailed int64 `json:"requests_failed"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// LogEntry is one recorded request.
|
||||
type LogEntry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status int `json:"status"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
InputTokens int `json:"input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Server wires the config store to the HTTP handlers.
|
||||
type Server struct {
|
||||
store *config.Store
|
||||
port int
|
||||
started time.Time
|
||||
|
||||
logs *logRing
|
||||
|
||||
statsMu sync.Mutex
|
||||
stats Stats
|
||||
}
|
||||
|
||||
// New builds a server bound to the given port.
|
||||
func New(store *config.Store, port int) *Server {
|
||||
return &Server{
|
||||
store: store,
|
||||
port: port,
|
||||
started: time.Now(),
|
||||
logs: newLogRing(100),
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the port the gateway listens on.
|
||||
func (s *Server) Port() int { return s.port }
|
||||
|
||||
// Handler returns the full HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages))
|
||||
mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens))
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
mux.HandleFunc("GET /api/providers", s.handleListProviders)
|
||||
mux.HandleFunc("POST /api/providers", s.handleCreateProvider)
|
||||
mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider)
|
||||
mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider)
|
||||
mux.HandleFunc("POST /api/providers/{id}/apply", s.handleApply)
|
||||
mux.HandleFunc("POST /api/restore", s.handleRestore)
|
||||
mux.HandleFunc("GET /api/backups", s.handleBackups)
|
||||
mux.HandleFunc("GET /api/settings", s.handleGetSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.handleUpdateSettings)
|
||||
mux.HandleFunc("GET /api/logs", s.handleLogs)
|
||||
return s.withLogging(mux)
|
||||
}
|
||||
|
||||
// withAuth protects the Anthropic-compatible endpoints with the local
|
||||
// relay token.
|
||||
func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := s.store.Config().RelayToken
|
||||
if token != "" {
|
||||
got := bearerToken(r)
|
||||
if got == "" {
|
||||
got = r.Header.Get("x-api-key")
|
||||
}
|
||||
if got != token {
|
||||
writeAnthropicError(w, http.StatusUnauthorized, "authentication_error", "invalid relay token")
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// record carries per-request fields from handlers to the logging
|
||||
// middleware.
|
||||
type record struct {
|
||||
mu sync.Mutex
|
||||
entry LogEntry
|
||||
}
|
||||
|
||||
type recordKey struct{}
|
||||
|
||||
func recordFrom(r *http.Request) *record {
|
||||
rec, _ := r.Context().Value(recordKey{}).(*record)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (rec *record) update(fn func(*LogEntry)) {
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
fn(&rec.entry)
|
||||
}
|
||||
|
||||
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &record{entry: LogEntry{
|
||||
Time: start,
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
}}
|
||||
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(ww, r.WithContext(context.WithValue(r.Context(), recordKey{}, rec)))
|
||||
|
||||
rec.mu.Lock()
|
||||
rec.entry.Status = ww.status
|
||||
rec.entry.DurationMS = time.Since(start).Milliseconds()
|
||||
entry := rec.entry
|
||||
rec.mu.Unlock()
|
||||
|
||||
s.logs.add(entry)
|
||||
s.statsMu.Lock()
|
||||
s.stats.RequestsTotal++
|
||||
if entry.Status >= 400 {
|
||||
s.stats.RequestsFailed++
|
||||
}
|
||||
s.stats.InputTokens += int64(entry.InputTokens)
|
||||
s.stats.OutputTokens += int64(entry.OutputTokens)
|
||||
s.statsMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.wroteHeader {
|
||||
w.status = code
|
||||
w.wroteHeader = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush keeps streaming responses unbuffered.
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) claudeManager() (*claudecfg.Manager, error) {
|
||||
path, err := claudecfg.DefaultPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claudecfg.New(path, filepath.Join(config.Dir(), "backups")), nil
|
||||
}
|
||||
|
||||
func (s *Server) snapshotStats() Stats {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
return s.stats
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeAnthropicError(w http.ResponseWriter, status int, errType, message string) {
|
||||
writeJSON(w, status, anthropic.ErrorResponse{
|
||||
Type: "error",
|
||||
Error: anthropic.Detail{Type: errType, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any, limit int64) error {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
|
||||
return dec.Decode(v)
|
||||
}
|
||||
Reference in new issue
Block a user