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)
}
}
+195
View File
@@ -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()})
}
+34
View File
@@ -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
}
+127
View File
@@ -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))
}
+228
View File
@@ -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)
}