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

+106
View File
@@ -0,0 +1,106 @@
package chatcmpl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// UpstreamError is returned when the upstream provider answers with a
// non-2xx status or an error payload.
type UpstreamError struct {
StatusCode int
Message string
}
func (e *UpstreamError) Error() string {
return fmt.Sprintf("upstream %d: %s", e.StatusCode, e.Message)
}
// Client talks to one Chat Completions endpoint.
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
}
// NewClient builds a client for the given base URL and API key.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
APIKey: apiKey,
HTTP: &http.Client{Timeout: 30 * time.Minute},
}
}
func (c *Client) endpoint() string {
return c.BaseURL + "/chat/completions"
}
func (c *Client) do(ctx context.Context, req *Request) (*http.Response, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
if c.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
}
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
msg := readErrorBody(resp)
return nil, &UpstreamError{StatusCode: resp.StatusCode, Message: msg}
}
return resp, nil
}
// Create performs a non-streaming completion.
func (c *Client) Create(ctx context.Context, req *Request) (*Response, error) {
resp, err := c.do(ctx, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out Response
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&out); err != nil {
return nil, fmt.Errorf("decode upstream response: %w", err)
}
if out.Error != nil && out.Error.Message != "" {
return nil, &UpstreamError{StatusCode: resp.StatusCode, Message: out.Error.Message}
}
return &out, nil
}
// CreateStream performs a streaming completion. The caller reads SSE
// events from the response body and must close it.
func (c *Client) CreateStream(ctx context.Context, req *Request) (*http.Response, error) {
return c.do(ctx, req)
}
func readErrorBody(resp *http.Response) string {
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil || len(data) == 0 {
return resp.Status
}
var payload struct {
Error *Error `json:"error"`
}
if json.Unmarshal(data, &payload) == nil && payload.Error != nil && payload.Error.Message != "" {
return payload.Error.Message
}
return strings.TrimSpace(string(data))
}
+117
View File
@@ -0,0 +1,117 @@
package chatcmpl
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateNonStream(t *testing.T) {
var gotAuth, gotBody string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
data, _ := io.ReadAll(r.Body)
gotBody = string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"cmpl-1","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk-up")
resp, err := client.Create(context.Background(), &Request{Model: "ds-chat", Messages: []Message{{Role: "user", Content: "hi"}}})
if err != nil {
t.Fatalf("Create: %v", err)
}
if gotAuth != "Bearer sk-up" {
t.Errorf("authorization = %q", gotAuth)
}
if !strings.Contains(gotBody, `"model":"ds-chat"`) {
t.Errorf("request body = %s", gotBody)
}
if len(resp.Choices) != 1 || resp.Choices[0].Message.Content != "hi" {
t.Fatalf("response = %+v", resp)
}
if resp.Usage == nil || resp.Usage.PromptTokens != 5 {
t.Errorf("usage = %+v", resp.Usage)
}
}
func TestCreateUpstreamError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"bad key","type":"invalid_api_key"}}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk-bad")
_, err := client.Create(context.Background(), &Request{Model: "m"})
var ue *UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("error = %v, want *UpstreamError", err)
}
if ue.StatusCode != http.StatusUnauthorized || ue.Message != "bad key" {
t.Errorf("upstream error = %+v", ue)
}
}
func TestCreateStreamRejectsNon2xx(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":{"message":"slow down"}}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk")
resp, err := client.CreateStream(context.Background(), &Request{Model: "m", Stream: true})
if err == nil {
resp.Body.Close()
t.Fatal("CreateStream succeeded on 429")
}
}
func TestStreamNext(t *testing.T) {
sse := strings.Join([]string{
`data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hel"}}]}`,
"",
`: keep-alive comment`,
`data: {"id":"1","choices":[{"index":0,"delta":{"content":"lo"}}]}`,
"",
`data: {"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`,
"",
"data: [DONE]",
"",
}, "\n")
stream := NewStream(strings.NewReader(sse))
first, err := stream.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if first.Choices[0].Delta.Content == nil || *first.Choices[0].Delta.Content != "Hel" {
t.Fatalf("first chunk = %+v", first)
}
second, err := stream.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if *second.Choices[0].Delta.Content != "lo" {
t.Fatalf("second chunk = %+v", second)
}
third, err := stream.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if third.Choices[0].FinishReason == nil || *third.Choices[0].FinishReason != "stop" {
t.Fatalf("third chunk = %+v", third)
}
if third.Usage == nil || third.Usage.CompletionTokens != 1 {
t.Fatalf("usage chunk = %+v", third)
}
if _, err := stream.Next(); err != io.EOF {
t.Fatalf("final Next err = %v, want io.EOF", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package chatcmpl
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strings"
)
// Stream reads Server-Sent Events carrying Chat Completions chunks.
type Stream struct {
scanner *bufio.Scanner
}
// NewStream wraps a reader of upstream SSE data.
func NewStream(r io.Reader) *Stream {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
return &Stream{scanner: sc}
}
// Next returns the next chunk. It reports io.EOF once the upstream
// stream is finished (either by "[DONE]" or by closing).
func (s *Stream) Next() (*StreamChunk, error) {
for s.scanner.Scan() {
line := strings.TrimRight(s.scanner.Text(), "\r")
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
if data == "[DONE]" {
return nil, io.EOF
}
var chunk StreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("decode upstream chunk: %w", err)
}
return &chunk, nil
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
+124
View File
@@ -0,0 +1,124 @@
// Package chatcmpl describes the OpenAI-compatible Chat Completions
// protocol spoken by upstream providers.
package chatcmpl
import "encoding/json"
// Request is a Chat Completions request body.
type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stop []string `json:"stop,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
}
// StreamOptions toggles the usage chunk at the end of a stream.
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// Message is one Chat Completions message. Content is either a string or
// a []ContentPart.
type Message struct {
Role string `json:"role"`
Content any `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ContentPart is one part of a multi-modal message.
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL references an image, usually as a data URL.
type ImageURL struct {
URL string `json:"url"`
}
// Tool is a function tool definition.
type Tool struct {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
// ToolFunction is the payload of a function tool.
type ToolFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// ToolCall is a function call requested by the model.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFunction `json:"function"`
Index int `json:"index,omitempty"`
}
// ToolCallFunction holds the name and the accumulated JSON arguments.
type ToolCallFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// Response is a non-streaming Chat Completions response body.
type Response struct {
ID string `json:"id"`
Object string `json:"object"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
Error *Error `json:"error,omitempty"`
}
// Choice is one completion choice.
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
// Usage reports token counts.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// Error is an upstream error payload.
type Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code,omitempty"`
}
// StreamChunk is one SSE data payload of a streaming response.
type StreamChunk struct {
ID string `json:"id"`
Choices []ChunkChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// ChunkChoice is one choice inside a stream chunk.
type ChunkChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// Delta is the incremental payload of a stream chunk.
type Delta struct {
Role string `json:"role,omitempty"`
Content *string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}