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

+9
View File
@@ -25,3 +25,12 @@ go.work.sum
# env file
.env
# ---> Node / Tauri
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
# ---> CrossRelay local build output
/bin/
+29
View File
@@ -0,0 +1,29 @@
ifeq ($(OS),Windows_NT)
GATEWAY_BIN := bin/gateway.exe
else
GATEWAY_BIN := bin/gateway
endif
.PHONY: all build test fmt vet dev dev-gateway clean
all: build
build: $(GATEWAY_BIN)
$(GATEWAY_BIN):
go build -o $(GATEWAY_BIN) ./cmd/gateway
test:
go test ./...
fmt:
go fmt ./...
vet:
go vet ./...
dev: build
npm run tauri dev
clean:
rm -rf bin
+32 -1
View File
@@ -1,3 +1,34 @@
# CrossRelay
跨工具管理AI中继代理
跨工具管理 AI 中继代理:面向 Claude Code 与 ChatGPT Codex 的跨协议配置管理与本地网关,不改官方文件,不依赖 CDP 注入。
## 架构
- `src-tauri/`:Tauri 2 桌面壳(React 前端 + Rust 壳),负责拉起网关子进程、解析端口、退出时清理。
- `cmd/gateway` + `internal/`:Go 网关,同时提供 `/v1/messages`(Anthropic Messages 协议)与管理 API `/api/*`,统一转换为上游 Chat Completions。
- 用户数据位于 `~/.crossrelay/`:`config.json`(供应商与设置)、`state.json`(运行期端口)、`backups/`(配置备份)。
## 开发
```powershell
# 1. 构建网关二进制到 bin/
make build
# 2. 安装前端依赖
npm install
# 3. 启动桌面端(自动构建网关并启动 Tauri dev)
make dev
```
## 测试
```powershell
make test # Go 单元测试与集成测试
npm test # 前端 vitest
npm run typecheck
```
## 当前范围
阶段一已完成:Claude Code 通路(`/v1/messages` 非流式 + SSE 流式、`~/.claude/settings.json` 注入与还原、供应商管理与中英双语界面)。Codex `/v1/responses`、MCP 双端同步、单二进制打包为后续阶段。
+116
View File
@@ -0,0 +1,116 @@
// Command gateway runs the CrossRelay local gateway: it serves the
// Anthropic Messages API for Claude Code and the management API for the
// CrossRelay desktop app.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
"gitea.rutua.cn/kate522/CrossRelay/internal/gateway"
)
// watchParentShutdown exits the gateway when the parent process goes
// away. The Tauri shell spawns the gateway with a piped stdin and sets
// CROSSRELAY_WATCH_PARENT=1; when the app exits, crashes, or is killed,
// the OS closes the pipe and the gateway shuts down instead of lingering
// as an orphan.
func watchParentShutdown(httpSrv *http.Server, statePath string, done chan struct{}) {
if os.Getenv("CROSSRELAY_WATCH_PARENT") != "1" {
return
}
go func() {
buf := make([]byte, 512)
for {
if _, err := os.Stdin.Read(buf); err != nil {
break
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
_ = os.Remove(statePath)
close(done)
}()
}
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("crossrelay: ")
store, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
addr := "127.0.0.1:0"
cfg := store.Config()
if cfg.Gateway.PortMode == config.PortModeFixed && cfg.Gateway.FixedPort > 0 {
addr = fmt.Sprintf("127.0.0.1:%d", cfg.Gateway.FixedPort)
}
ln, err := net.Listen("tcp", addr)
if err != nil {
fmt.Fprintf(os.Stderr, "CROSSRELAY_LISTEN_FAILED %s\n", err)
os.Exit(1)
}
port := ln.Addr().(*net.TCPAddr).Port
srv := gateway.New(store, port)
httpSrv := &http.Server{Handler: srv.Handler()}
statePath := filepath.Join(config.Dir(), "state.json")
if err := writeState(statePath, port); err != nil {
log.Printf("write state file: %v", err)
}
// The desktop app parses this line to learn the actual port.
fmt.Printf("CROSSRELAY_LISTENING 127.0.0.1:%d\n", port)
shutdownDone := make(chan struct{})
watchParentShutdown(httpSrv, statePath, shutdownDone)
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
_ = os.Remove(statePath)
close(shutdownDone)
}()
if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "CROSSRELAY_SERVE_FAILED %s\n", err)
_ = os.Remove(statePath)
os.Exit(1)
}
<-shutdownDone
}
// writeState records the running gateway for diagnostics.
func writeState(path string, port int) error {
state := map[string]any{
"pid": os.Getpid(),
"port": port,
"started_at": time.Now().UTC().Format(time.RFC3339),
"version": gateway.Version,
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
+3
View File
@@ -0,0 +1,3 @@
module gitea.rutua.cn/kate522/CrossRelay
go 1.26
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossRelay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+276
View File
@@ -0,0 +1,276 @@
package anthropic
import (
"encoding/json"
"fmt"
"strings"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// ConvertRequest converts an Anthropic Messages request into a Chat
// Completions request addressed to the given upstream model.
func ConvertRequest(req *MessagesRequest, model string) (*chatcmpl.Request, error) {
out := &chatcmpl.Request{
Model: model,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Stop: req.StopSequences,
Stream: req.Stream,
}
if txt := SystemText(req.System); txt != "" {
out.Messages = append(out.Messages, chatcmpl.Message{Role: "system", Content: txt})
}
for _, m := range req.Messages {
blocks, err := ParseContent(m.Content)
if err != nil {
return nil, fmt.Errorf("parse message content: %w", err)
}
converted, err := convertMessage(m.Role, blocks)
if err != nil {
return nil, err
}
out.Messages = append(out.Messages, converted...)
}
for _, t := range req.Tools {
out.Tools = append(out.Tools, chatcmpl.Tool{
Type: "function",
Function: chatcmpl.ToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
},
})
}
choice, err := convertToolChoice(req.ToolChoice)
if err != nil {
return nil, err
}
out.ToolChoice = choice
if req.Stream {
out.StreamOptions = &chatcmpl.StreamOptions{IncludeUsage: true}
}
return out, nil
}
func convertMessage(role string, blocks []Block) ([]chatcmpl.Message, error) {
var out []chatcmpl.Message
var parts []chatcmpl.ContentPart
flush := func() {
if len(parts) == 0 {
return
}
if len(parts) == 1 && parts[0].Type == "text" {
out = append(out, chatcmpl.Message{Role: role, Content: parts[0].Text})
} else {
out = append(out, chatcmpl.Message{Role: role, Content: parts})
}
parts = nil
}
for _, b := range blocks {
switch b.Type {
case "text":
parts = append(parts, chatcmpl.ContentPart{Type: "text", Text: b.Text})
case "image":
url, err := imageURL(b.Source)
if err != nil {
return nil, err
}
parts = append(parts, chatcmpl.ContentPart{Type: "image_url", ImageURL: &chatcmpl.ImageURL{URL: url}})
case "tool_use":
flush()
args := string(b.Input)
if strings.TrimSpace(args) == "" {
args = "{}"
}
out = append(out, chatcmpl.Message{
Role: "assistant",
ToolCalls: []chatcmpl.ToolCall{{
ID: b.ID,
Type: "function",
Function: chatcmpl.ToolCallFunction{Name: b.Name, Arguments: args},
}},
})
case "tool_result":
flush()
out = append(out, chatcmpl.Message{
Role: "tool",
ToolCallID: b.ToolUseID,
Content: TextOf(b.Content),
})
}
}
flush()
return out, nil
}
func imageURL(src *ImageSource) (string, error) {
if src == nil {
return "", fmt.Errorf("image block without source")
}
switch {
case src.URL != "":
return src.URL, nil
case src.Type == "base64" && src.Data != "":
mediaType := src.MediaType
if mediaType == "" {
mediaType = "image/png"
}
return "data:" + mediaType + ";base64," + src.Data, nil
default:
return "", fmt.Errorf("unsupported image source type %q", src.Type)
}
}
func convertToolChoice(raw json.RawMessage) (any, error) {
if len(raw) == 0 {
return nil, nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return toolChoiceFromType(s)
}
var obj struct {
Type string `json:"type"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &obj) == nil {
if obj.Type == "tool" && obj.Name != "" {
return map[string]any{
"type": "function",
"function": map[string]any{"name": obj.Name},
}, nil
}
return toolChoiceFromType(obj.Type)
}
return nil, nil
}
func toolChoiceFromType(t string) (any, error) {
switch t {
case "auto", "none":
return t, nil
case "any":
return "required", nil
case "":
return nil, nil
default:
// Unknown tool choice: let the upstream decide.
return nil, nil
}
}
// ConvertResponse converts a Chat Completions response into an Anthropic
// Messages response. requestedModel is echoed back to the client.
func ConvertResponse(resp *chatcmpl.Response, requestedModel string) *MessagesResponse {
out := &MessagesResponse{
ID: "msg_" + resp.ID,
Type: "message",
Role: "assistant",
Model: requestedModel,
Content: []Block{},
}
if len(resp.Choices) > 0 {
c := resp.Choices[0]
out.Content = convertAssistantMessage(c.Message)
out.StopReason = MapStopReason(c.FinishReason)
} else {
out.StopReason = "end_turn"
}
if resp.Usage != nil {
out.Usage = Usage{
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}
}
return out
}
func convertAssistantMessage(m chatcmpl.Message) []Block {
var blocks []Block
if txt := messageText(m.Content); txt != "" {
blocks = append(blocks, Block{Type: "text", Text: txt})
}
for _, tc := range m.ToolCalls {
input := json.RawMessage(tc.Function.Arguments)
if len(input) == 0 {
input = json.RawMessage("{}")
}
blocks = append(blocks, Block{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
return blocks
}
func messageText(content any) string {
switch v := content.(type) {
case string:
return v
case []chatcmpl.ContentPart:
var sb strings.Builder
for _, p := range v {
if p.Type == "text" {
sb.WriteString(p.Text)
}
}
return sb.String()
default:
return ""
}
}
// MapStopReason maps a Chat Completions finish_reason onto an Anthropic
// stop_reason.
func MapStopReason(finish string) string {
switch finish {
case "stop", "":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls", "function_call":
return "tool_use"
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
// EstimateTokens returns a rough input-token estimate for a request. It
// powers /v1/messages/count_tokens so Claude Code's context meter works
// without a real tokenizer.
func EstimateTokens(req *MessagesRequest) int {
total := len(req.Model)/4 + 4
total += len(SystemText(req.System)) / 4
for _, m := range req.Messages {
total += len(m.Role) + 4
blocks, err := ParseContent(m.Content)
if err != nil {
continue
}
for _, b := range blocks {
switch b.Type {
case "text":
total += len(b.Text) / 4
case "tool_use":
total += len(b.Name)/4 + len(b.Input)/4
case "tool_result":
total += len(TextOf(b.Content)) / 4
case "image":
total += 1100
}
}
}
for _, t := range req.Tools {
total += len(t.Name)/4 + len(t.Description)/4 + len(t.InputSchema)/4
}
if total < 1 {
total = 1
}
return total
}
+241
View File
@@ -0,0 +1,241 @@
package anthropic
import (
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
func ptr[T any](v T) *T { return &v }
func TestConvertRequestTextAndSystem(t *testing.T) {
req := &MessagesRequest{
Model: "claude-sonnet-4-5",
MaxTokens: 1024,
System: json.RawMessage(`"You are helpful"`),
Messages: []Message{
{Role: "user", Content: json.RawMessage(`"hi"`)},
},
Temperature: ptr(0.5),
TopP: ptr(0.9),
}
out, err := ConvertRequest(req, "ds-chat")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if out.Model != "ds-chat" {
t.Errorf("model = %q", out.Model)
}
if out.MaxTokens != 1024 {
t.Errorf("max tokens = %d", out.MaxTokens)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (system + user)", len(out.Messages))
}
if out.Messages[0].Role != "system" || out.Messages[0].Content != "You are helpful" {
t.Errorf("system message = %+v", out.Messages[0])
}
if out.Messages[1].Role != "user" || out.Messages[1].Content != "hi" {
t.Errorf("user message = %+v", out.Messages[1])
}
if out.StreamOptions != nil {
t.Error("stream_options set for non-stream request")
}
}
func TestConvertRequestSystemBlocks(t *testing.T) {
req := &MessagesRequest{
Model: "m",
System: json.RawMessage(`[{"type":"text","text":"one"},{"type":"text","text":"two","cache_control":{"type":"ephemeral"}}]`),
}
out, err := ConvertRequest(req, "m")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if len(out.Messages) != 1 || out.Messages[0].Content != "one\n\ntwo" {
t.Fatalf("system flattening = %+v", out.Messages)
}
}
func TestConvertRequestToolsAndToolResult(t *testing.T) {
req := &MessagesRequest{
Model: "claude-sonnet-4-5",
Messages: []Message{
{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"look"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAA="}}]`)},
{Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"read_file","input":{"path":"a.txt"}}]`)},
{Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"file body"}]`)},
},
Tools: []Tool{{Name: "read_file", Description: "reads", InputSchema: json.RawMessage(`{"type":"object"}`)}},
}
out, err := ConvertRequest(req, "ds-chat")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
wantRoles := []string{"user", "assistant", "assistant", "tool"}
if len(out.Messages) != len(wantRoles) {
t.Fatalf("messages = %d (%+v), want %d", len(out.Messages), out.Messages, len(wantRoles))
}
for i, want := range wantRoles {
if out.Messages[i].Role != want {
t.Errorf("message %d role = %q, want %q", i, out.Messages[i].Role, want)
}
}
// user message became multi-part with an image data URL.
parts, ok := out.Messages[0].Content.([]chatcmpl.ContentPart)
if !ok || len(parts) != 2 || parts[1].Type != "image_url" {
t.Fatalf("user content = %+v", out.Messages[0].Content)
}
if parts[1].ImageURL.URL != "data:image/png;base64,AAA=" {
t.Errorf("image url = %q", parts[1].ImageURL.URL)
}
// assistant tool_use became a tool_calls message.
if len(out.Messages[2].ToolCalls) != 1 {
t.Fatalf("tool calls = %+v", out.Messages[2].ToolCalls)
}
tc := out.Messages[2].ToolCalls[0]
if tc.ID != "toolu_1" || tc.Function.Name != "read_file" || tc.Function.Arguments != `{"path":"a.txt"}` {
t.Errorf("tool call = %+v", tc)
}
// tool_result became a tool message.
if out.Messages[3].ToolCallID != "toolu_1" || out.Messages[3].Content != "file body" {
t.Errorf("tool message = %+v", out.Messages[3])
}
// tools converted to function tools.
if len(out.Tools) != 1 || out.Tools[0].Type != "function" || out.Tools[0].Function.Name != "read_file" {
t.Errorf("tools = %+v", out.Tools)
}
if string(out.Tools[0].Function.Parameters) != `{"type":"object"}` {
t.Errorf("tool parameters = %s", out.Tools[0].Function.Parameters)
}
}
func TestConvertRequestStreamSetsUsageOption(t *testing.T) {
out, err := ConvertRequest(&MessagesRequest{Model: "m", Stream: true}, "m")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if out.StreamOptions == nil || !out.StreamOptions.IncludeUsage {
t.Errorf("stream options = %+v, want include_usage", out.StreamOptions)
}
}
func TestConvertToolChoice(t *testing.T) {
cases := []struct {
raw string
want any
}{
{`"auto"`, "auto"},
{`"any"`, "required"},
{`"none"`, "none"},
{`{"type":"tool","name":"read_file"}`, map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}},
{`{"type":"auto"}`, "auto"},
{`{"type":"weird"}`, nil},
{`"weird"`, nil},
}
for _, tc := range cases {
got, err := convertToolChoice(json.RawMessage(tc.raw))
if err != nil {
t.Fatalf("convertToolChoice(%s): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("convertToolChoice(%s) = %#v, want %#v", tc.raw, got, tc.want)
}
}
}
func TestConvertResponse(t *testing.T) {
resp := &chatcmpl.Response{
ID: "chatcmpl-1",
Model: "ds-chat",
Choices: []chatcmpl.Choice{{
FinishReason: "tool_calls",
Message: chatcmpl.Message{
Role: "assistant",
Content: "thinking",
ToolCalls: []chatcmpl.ToolCall{{
ID: "call_1",
Type: "function",
Function: chatcmpl.ToolCallFunction{
Name: "read_file",
Arguments: `{"path":"a.txt"}`,
},
}},
},
}},
Usage: &chatcmpl.Usage{PromptTokens: 11, CompletionTokens: 7},
}
out := ConvertResponse(resp, "claude-sonnet-4-5")
if out.Type != "message" || out.Role != "assistant" {
t.Errorf("envelope = %+v", out)
}
if out.Model != "claude-sonnet-4-5" {
t.Errorf("model = %q, want the requested model echoed", out.Model)
}
if len(out.Content) != 2 {
t.Fatalf("content = %+v", out.Content)
}
if out.Content[0].Type != "text" || out.Content[0].Text != "thinking" {
t.Errorf("text block = %+v", out.Content[0])
}
if out.Content[1].Type != "tool_use" || out.Content[1].ID != "call_1" || out.Content[1].Name != "read_file" {
t.Errorf("tool block = %+v", out.Content[1])
}
if string(out.Content[1].Input) != `{"path":"a.txt"}` {
t.Errorf("tool input = %s", out.Content[1].Input)
}
if out.StopReason != "tool_use" {
t.Errorf("stop reason = %q", out.StopReason)
}
if out.Usage.InputTokens != 11 || out.Usage.OutputTokens != 7 {
t.Errorf("usage = %+v", out.Usage)
}
}
func TestMapStopReason(t *testing.T) {
cases := map[string]string{
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"function_call": "tool_use",
"content_filter": "refusal",
"": "end_turn",
"mystery": "end_turn",
}
for finish, want := range cases {
if got := MapStopReason(finish); got != want {
t.Errorf("MapStopReason(%q) = %q, want %q", finish, got, want)
}
}
}
func TestEstimateTokens(t *testing.T) {
small := &MessagesRequest{Model: "m", Messages: []Message{{Role: "user", Content: json.RawMessage(`"hi"`)}}}
long := strings.Repeat("token ", 800)
big := &MessagesRequest{Model: "m", Messages: []Message{{Role: "user", Content: json.RawMessage(`"` + long + `"`)}}}
if EstimateTokens(small) <= 0 {
t.Error("estimate should be positive")
}
if EstimateTokens(big) <= EstimateTokens(small)*10 {
t.Errorf("estimate does not scale: small=%d big=%d", EstimateTokens(small), EstimateTokens(big))
}
}
func TestParseContentStringAndBlocks(t *testing.T) {
blocks, err := ParseContent(json.RawMessage(`"plain"`))
if err != nil || len(blocks) != 1 || blocks[0].Type != "text" || blocks[0].Text != "plain" {
t.Fatalf("string content = %+v (%v)", blocks, err)
}
blocks, err = ParseContent(json.RawMessage(`null`))
if err != nil || blocks != nil {
t.Fatalf("null content = %+v (%v)", blocks, err)
}
if TextOf(json.RawMessage(`"x"`)) != "x" {
t.Error("TextOf(string) failed")
}
if TextOf(json.RawMessage(`[{"type":"text","text":"a"},{"type":"text","text":"b"}]`)) != "ab" {
t.Error("TextOf(blocks) failed")
}
}
+32
View File
@@ -0,0 +1,32 @@
package anthropic
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
// flusherOf extracts the http.Flusher when the writer supports it.
func flusherOf(w http.ResponseWriter) http.Flusher {
if f, ok := w.(http.Flusher); ok {
return f
}
return nil
}
// writeEvent emits one Server-Sent Event in Anthropic's format:
// "event: <name>\ndata: <json>\n\n".
func writeEvent(w io.Writer, f http.Flusher, name string, data any) error {
payload, err := json.Marshal(data)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, payload); err != nil {
return err
}
if f != nil {
f.Flush()
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package anthropic
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// StreamConverter synthesizes Anthropic SSE events from Chat Completions
// stream chunks.
//
// Content blocks are tracked by their Anthropic index: one text block
// (opened on the first text delta) and one tool_use block per upstream
// tool-call index. Deltas always carry the block index, so interleaved
// tool-call arguments from the upstream stream are attributed to the
// right block. All open blocks are closed in Finish, followed by
// message_delta and message_stop.
type StreamConverter struct {
w http.ResponseWriter
flusher http.Flusher
model string
msgID string
started bool
nextIndex int
textOpen bool
textIndex int
tools map[int]*toolBlock
toolOrder []int
inputEstimate int
outputChars int
usage *chatcmpl.Usage
finishReason string
}
type toolBlock struct {
index int
id string
name string
started bool
pending string
}
// NewStreamConverter builds a converter writing to w. model is echoed in
// message_start; inputTokens seeds the usage shown to the client.
func NewStreamConverter(w http.ResponseWriter, model string, inputTokens int) *StreamConverter {
return &StreamConverter{
w: w,
flusher: flusherOf(w),
model: model,
msgID: "msg_" + randHex(16),
tools: map[int]*toolBlock{},
inputEstimate: inputTokens,
}
}
// WriteStart sends the SSE headers and the message_start event.
func (s *StreamConverter) WriteStart() error {
h := s.w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
s.w.WriteHeader(http.StatusOK)
s.started = true
return writeEvent(s.w, s.flusher, "message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": s.msgID,
"type": "message",
"role": "assistant",
"content": []any{},
"model": s.model,
"stop_reason": nil,
"stop_sequence": nil,
"usage": map[string]any{"input_tokens": s.inputEstimate, "output_tokens": 0},
},
})
}
// OnChunk folds one upstream chunk into the Anthropic event stream.
func (s *StreamConverter) OnChunk(chunk *chatcmpl.StreamChunk) error {
if chunk == nil {
return nil
}
if chunk.Usage != nil {
u := *chunk.Usage
s.usage = &u
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
if err := s.writeText(*choice.Delta.Content); err != nil {
return err
}
}
for _, tc := range choice.Delta.ToolCalls {
if err := s.toolDelta(tc); err != nil {
return err
}
}
if choice.FinishReason != nil && *choice.FinishReason != "" {
s.finishReason = *choice.FinishReason
}
}
return nil
}
func (s *StreamConverter) writeText(text string) error {
if !s.textOpen {
s.textIndex = s.nextIndex
s.nextIndex++
s.textOpen = true
if err := writeEvent(s.w, s.flusher, "content_block_start", map[string]any{
"type": "content_block_start",
"index": s.textIndex,
"content_block": map[string]any{"type": "text", "text": ""},
}); err != nil {
return err
}
}
s.outputChars += len(text)
return writeEvent(s.w, s.flusher, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": s.textIndex,
"delta": map[string]any{"type": "text_delta", "text": text},
})
}
func (s *StreamConverter) toolDelta(tc chatcmpl.ToolCall) error {
st := s.tools[tc.Index]
if st == nil {
st = &toolBlock{}
s.tools[tc.Index] = st
s.toolOrder = append(s.toolOrder, tc.Index)
}
if tc.ID != "" && st.id == "" {
st.id = tc.ID
}
if tc.Function.Name != "" && st.name == "" {
st.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
st.pending += tc.Function.Arguments
}
// Open the block once we know a name or have arguments to flush, so
// the client never sees an unnamed tool_use block.
if !st.started && (st.name != "" || st.pending != "") {
st.index = s.nextIndex
s.nextIndex++
st.started = true
if st.id == "" {
st.id = fmt.Sprintf("toolu_%s_%d", s.msgID[len("msg_"):], tc.Index)
}
if err := writeEvent(s.w, s.flusher, "content_block_start", map[string]any{
"type": "content_block_start",
"index": st.index,
"content_block": map[string]any{
"type": "tool_use",
"id": st.id,
"name": st.name,
"input": map[string]any{},
},
}); err != nil {
return err
}
s.outputChars += len(st.name)
}
if st.started && st.pending != "" {
s.outputChars += len(st.pending)
pending := st.pending
st.pending = ""
return writeEvent(s.w, s.flusher, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": st.index,
"delta": map[string]any{"type": "input_json_delta", "partial_json": pending},
})
}
return nil
}
// Finish closes every open block and ends the message.
func (s *StreamConverter) Finish() error {
if s.textOpen {
if err := writeEvent(s.w, s.flusher, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": s.textIndex,
}); err != nil {
return err
}
s.textOpen = false
}
for _, idx := range s.toolOrder {
st := s.tools[idx]
if st == nil || !st.started {
continue
}
if err := writeEvent(s.w, s.flusher, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": st.index,
}); err != nil {
return err
}
}
outputTokens := 0
if s.usage != nil {
outputTokens = s.usage.CompletionTokens
} else {
outputTokens = (s.outputChars + 3) / 4
}
usage := map[string]any{"output_tokens": outputTokens}
if s.usage != nil {
usage["input_tokens"] = s.usage.PromptTokens
}
if err := writeEvent(s.w, s.flusher, "message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": MapStopReason(s.finishReason), "stop_sequence": nil},
"usage": usage,
}); err != nil {
return err
}
return writeEvent(s.w, s.flusher, "message_stop", map[string]any{"type": "message_stop"})
}
// WriteError terminates the stream with an Anthropic error event.
func (s *StreamConverter) WriteError(errType, message string) {
_ = writeEvent(s.w, s.flusher, "error", map[string]any{
"type": "error",
"error": map[string]any{"type": errType, "message": message},
})
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", n)
}
return hex.EncodeToString(b)
}
+277
View File
@@ -0,0 +1,277 @@
package anthropic
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// captureWriter records everything the converter writes.
type captureWriter struct {
header http.Header
body bytes.Buffer
status int
flushes int
}
func newCaptureWriter() *captureWriter {
return &captureWriter{header: http.Header{}}
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) Write(b []byte) (int, error) { return c.body.Write(b) }
func (c *captureWriter) WriteHeader(status int) { c.status = status }
func (c *captureWriter) Flush() { c.flushes++ }
type sseEvent struct {
name string
data map[string]any
}
func parseSSE(t *testing.T, data string) []sseEvent {
t.Helper()
var events []sseEvent
for _, block := range strings.Split(strings.TrimSpace(data), "\n\n") {
if block == "" {
continue
}
var name string
var payload string
for _, line := range strings.Split(block, "\n") {
switch {
case strings.HasPrefix(line, "event: "):
name = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
payload = strings.TrimPrefix(line, "data: ")
}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
t.Fatalf("event %s payload is not JSON: %v (%s)", name, err, payload)
}
events = append(events, sseEvent{name: name, data: parsed})
}
return events
}
func eventNames(events []sseEvent) []string {
names := make([]string, len(events))
for i, e := range events {
names[i] = e.name
}
return names
}
func textChunk(text string) *chatcmpl.StreamChunk {
return &chatcmpl.StreamChunk{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{Content: &text}}}}
}
func finishChunk(reason string, usage *chatcmpl.Usage) *chatcmpl.StreamChunk {
return &chatcmpl.StreamChunk{
Choices: []chatcmpl.ChunkChoice{{FinishReason: &reason}},
Usage: usage,
}
}
func runConverter(t *testing.T, chunks []*chatcmpl.StreamChunk) []sseEvent {
t.Helper()
w := newCaptureWriter()
conv := NewStreamConverter(w, "claude-sonnet-4-5", 42)
if err := conv.WriteStart(); err != nil {
t.Fatalf("WriteStart: %v", err)
}
for _, c := range chunks {
if err := conv.OnChunk(c); err != nil {
t.Fatalf("OnChunk: %v", err)
}
}
if err := conv.Finish(); err != nil {
t.Fatalf("Finish: %v", err)
}
if w.status != http.StatusOK {
t.Errorf("status = %d", w.status)
}
if ct := w.header.Get("Content-Type"); ct != "text/event-stream; charset=utf-8" {
t.Errorf("content type = %q", ct)
}
return parseSSE(t, w.body.String())
}
func TestStreamTextOnly(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
textChunk("Hello"),
textChunk(", world"),
finishChunk("stop", &chatcmpl.Usage{PromptTokens: 10, CompletionTokens: 3}),
})
wantNames := []string{
"message_start",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
}
if got := eventNames(events); !equalStrings(got, wantNames) {
t.Fatalf("events = %v, want %v", got, wantNames)
}
start := events[0].data["message"].(map[string]any)
if start["model"] != "claude-sonnet-4-5" {
t.Errorf("message model = %v", start["model"])
}
usage := start["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 42 {
t.Errorf("input tokens = %v, want 42", usage["input_tokens"])
}
if events[2].data["delta"].(map[string]any)["text"] != "Hello" {
t.Errorf("first delta = %v", events[2].data["delta"])
}
delta := events[5].data["delta"].(map[string]any)
if delta["stop_reason"] != "end_turn" {
t.Errorf("stop reason = %v", delta["stop_reason"])
}
if events[5].data["usage"].(map[string]any)["output_tokens"].(float64) != 3 {
t.Errorf("output tokens = %v", events[5].data["usage"])
}
}
func TestStreamToolCall(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0,
ID: "call_1",
Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "read_file"},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0,
Function: chatcmpl.ToolCallFunction{Arguments: `{"path":`},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0,
Function: chatcmpl.ToolCallFunction{Arguments: `"a.txt"}`},
}}}}}},
finishChunk("tool_calls", nil),
})
wantNames := []string{
"message_start",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
}
if got := eventNames(events); !equalStrings(got, wantNames) {
t.Fatalf("events = %v, want %v", got, wantNames)
}
block := events[1].data["content_block"].(map[string]any)
if block["type"] != "tool_use" || block["id"] != "call_1" || block["name"] != "read_file" {
t.Fatalf("tool block = %v", block)
}
if events[2].data["delta"].(map[string]any)["partial_json"] != `{"path":` {
t.Errorf("first args delta = %v", events[2].data["delta"])
}
if events[3].data["delta"].(map[string]any)["partial_json"] != `"a.txt"}` {
t.Errorf("second args delta = %v", events[3].data["delta"])
}
// Every event for the same block carries index 0.
for _, e := range events[1:5] {
if e.data["index"].(float64) != 0 {
t.Errorf("%s index = %v, want 0", e.name, e.data["index"])
}
}
if events[5].data["delta"].(map[string]any)["stop_reason"] != "tool_use" {
t.Errorf("stop reason = %v", events[5].data["delta"])
}
// No usage chunk from upstream: output tokens are estimated, not zero.
if events[5].data["usage"].(map[string]any)["output_tokens"].(float64) <= 0 {
t.Error("estimated output tokens should be positive")
}
}
func TestStreamParallelToolCalls(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, ID: "call_a", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "tool_a"},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 1, ID: "call_b", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "tool_b", Arguments: `{}`},
}}}}}},
finishChunk("tool_calls", nil),
})
// Two separate blocks: index 0 for tool_a, index 1 for tool_b.
starts := 0
names := map[float64]string{}
for _, e := range events {
if e.name != "content_block_start" {
continue
}
starts++
idx := e.data["index"].(float64)
names[idx] = e.data["content_block"].(map[string]any)["name"].(string)
}
if starts != 2 {
t.Fatalf("content_block_start count = %d, want 2", starts)
}
if names[0] != "tool_a" || names[1] != "tool_b" {
t.Errorf("block names = %v", names)
}
}
func TestStreamInterleavedToolDeltas(t *testing.T) {
// Upstream sends argument deltas for two indices in an interleaved
// order; each delta must land on its own block index.
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 0, ID: "a", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "tool_a"}},
}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 1, ID: "b", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "tool_b"}},
}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"x":1}`}},
}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 1, Function: chatcmpl.ToolCallFunction{Arguments: `{"y":2}`}},
}}}}},
finishChunk("tool_calls", nil),
})
args := map[float64]string{}
for _, e := range events {
if e.name != "content_block_delta" {
continue
}
idx := e.data["index"].(float64)
args[idx] += e.data["delta"].(map[string]any)["partial_json"].(string)
}
if args[0] != `{"x":1}` || args[1] != `{"y":2}` {
t.Errorf("interleaved args = %v", args)
}
}
func TestStreamEmptyUpstream(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{})
want := []string{"message_start", "message_delta", "message_stop"}
if got := eventNames(events); !equalStrings(got, want) {
t.Fatalf("events = %v, want %v", got, want)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+166
View File
@@ -0,0 +1,166 @@
// Package anthropic describes the Anthropic Messages API that Claude
// Code speaks and converts it to and from Chat Completions.
package anthropic
import (
"bytes"
"encoding/json"
)
// MessagesRequest is the Anthropic Messages API request body.
type MessagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []Message `json:"messages"`
System json.RawMessage `json:"system,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// Message is one conversation turn. Content is either a plain string or
// an array of blocks.
type Message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
// Tool is an Anthropic tool definition.
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"`
}
// Block is a single content block inside a message. Only the fields
// belonging to Type are populated.
type Block struct {
Type string `json:"type"`
// text
Text string `json:"text,omitempty"`
// image
Source *ImageSource `json:"source,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
// cache_control is accepted but never forwarded upstream.
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
// ImageSource is an image referenced by a user message.
type ImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
Data string `json:"data,omitempty"`
URL string `json:"url,omitempty"`
}
// MessagesResponse is the Anthropic Messages API response body.
type MessagesResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []Block `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence"`
Usage Usage `json:"usage"`
}
// Usage reports token counts.
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// ErrorResponse is the Anthropic error envelope.
type ErrorResponse struct {
Type string `json:"type"`
Error Detail `json:"error"`
}
// Detail is the error payload.
type Detail struct {
Type string `json:"type"`
Message string `json:"message"`
}
// CountTokensResponse is the response of /v1/messages/count_tokens.
type CountTokensResponse struct {
InputTokens int `json:"input_tokens"`
}
// ParseContent decodes a message content field into blocks. A plain
// string becomes a single text block.
func ParseContent(raw json.RawMessage) ([]Block, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return nil, err
}
if s == "" {
return nil, nil
}
return []Block{{Type: "text", Text: s}}, nil
}
var blocks []Block
if err := json.Unmarshal(trimmed, &blocks); err != nil {
return nil, err
}
return blocks, nil
}
// SystemText flattens a system field (string or block array) into plain
// text.
func SystemText(raw json.RawMessage) string {
blocks, err := ParseContent(raw)
if err != nil {
return ""
}
var parts []string
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
}
}
return joinBlocks(parts)
}
// TextOf extracts plain text from a tool_result content field.
func TextOf(raw json.RawMessage) string {
blocks, err := ParseContent(raw)
if err != nil {
return ""
}
var sb bytes.Buffer
for _, b := range blocks {
if b.Type == "text" {
sb.WriteString(b.Text)
}
}
return sb.String()
}
func joinBlocks(parts []string) string {
if len(parts) == 0 {
return ""
}
out := parts[0]
for _, p := range parts[1:] {
out += "\n\n" + p
}
return out
}
+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"`
}
+303
View File
@@ -0,0 +1,303 @@
// Package claudecfg reads and writes Claude Code's settings.json.
// CrossRelay only touches the env keys it manages; every other key
// (theme, permissions, unrelated env vars) round-trips untouched.
package claudecfg
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// ManagedEnvKeys lists the settings.json env keys CrossRelay owns.
var ManagedEnvKeys = []string{
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
"ANTHROPIC_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
}
// DefaultPath returns the location of Claude Code's settings.json.
// CROSSRELAY_CLAUDE_SETTINGS overrides it (used by tests).
func DefaultPath() (string, error) {
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_CLAUDE_SETTINGS")); v != "" {
return v, nil
}
dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("locate home directory: %w", err)
}
dir = filepath.Join(home, ".claude")
}
return filepath.Join(dir, "settings.json"), nil
}
// Manager performs guarded edits on one settings.json file.
type Manager struct {
path string
backupDir string
mu sync.Mutex
}
// New builds a manager for settingsPath, storing backups in backupDir.
func New(settingsPath, backupDir string) *Manager {
return &Manager{path: settingsPath, backupDir: backupDir}
}
// ApplyOptions describes what gets injected into settings.json.
type ApplyOptions struct {
BaseURL string
Token string
Models config.Models
}
// Settings is a parsed settings.json. Raw keeps every top-level key.
type Settings struct {
Raw map[string]json.RawMessage `json:"-"`
Env map[string]string `json:"env"`
}
// UnmarshalJSON parses settings.json while keeping unknown keys.
func (s *Settings) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
s.Raw = raw
s.Env = nil
if envRaw, ok := raw["env"]; ok {
_ = json.Unmarshal(envRaw, &s.Env)
}
return nil
}
// MarshalJSON writes every original key back, env included.
func (s Settings) MarshalJSON() ([]byte, error) {
out := make(map[string]json.RawMessage, len(s.Raw)+1)
for k, v := range s.Raw {
out[k] = v
}
env, err := json.Marshal(s.Env)
if err != nil {
return nil, err
}
out["env"] = env
return json.Marshal(out)
}
// Apply injects the relay configuration, backing up the current file
// first. It returns the resulting settings.
func (m *Manager) Apply(opts ApplyOptions) (*Settings, error) {
m.mu.Lock()
defer m.mu.Unlock()
settings, err := m.load()
if err != nil {
return nil, err
}
if _, err := m.backupLocked(settings); err != nil {
return nil, err
}
if settings.Env == nil {
settings.Env = map[string]string{}
}
settings.Env["ANTHROPIC_BASE_URL"] = opts.BaseURL
settings.Env["ANTHROPIC_AUTH_TOKEN"] = opts.Token
applyModelEnv(settings.Env, opts.Models)
if err := m.saveLocked(settings); err != nil {
return nil, err
}
return settings, nil
}
// Restore copies the newest backup over settings.json and returns the
// backup file name.
func (m *Manager) Restore() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
backups, err := m.listBackupsLocked()
if err != nil {
return "", err
}
if len(backups) == 0 {
return "", fmt.Errorf("no backup found in %s", m.backupDir)
}
newest := backups[0]
data, err := os.ReadFile(filepath.Join(m.backupDir, newest.Name))
if err != nil {
return "", err
}
if err := m.writeFile(data); err != nil {
return "", err
}
return newest.Name, nil
}
// BackupInfo describes one backup file.
type BackupInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Backups lists backup files, newest first.
func (m *Manager) Backups() ([]BackupInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.listBackupsLocked()
}
// CurrentEnv returns the env block of the live settings.json.
func (m *Manager) CurrentEnv() (map[string]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
settings, err := m.load()
if err != nil {
return nil, err
}
return settings.Env, nil
}
func (m *Manager) load() (*Settings, error) {
data, err := os.ReadFile(m.path)
if err != nil {
if os.IsNotExist(err) {
return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil
}
return nil, err
}
if len(data) == 0 {
return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil
}
var settings Settings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, fmt.Errorf("parse %s: %w", m.path, err)
}
if settings.Raw == nil {
settings.Raw = map[string]json.RawMessage{}
}
if settings.Env == nil {
settings.Env = map[string]string{}
}
return &settings, nil
}
func (m *Manager) saveLocked(settings *Settings) error {
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return m.writeFile(data)
}
func (m *Manager) writeFile(data []byte) error {
if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil {
return err
}
tmp := m.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, m.path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (m *Manager) backupLocked(settings *Settings) (string, error) {
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return "", err
}
data = append(data, '\n')
if err := os.MkdirAll(m.backupDir, 0o700); err != nil {
return "", err
}
name := fmt.Sprintf("settings-%s.json", time.Now().Format("20060102-150405"))
path := filepath.Join(m.backupDir, name)
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
m.pruneBackupsLocked(20)
return name, nil
}
func (m *Manager) listBackupsLocked() ([]BackupInfo, error) {
entries, err := os.ReadDir(m.backupDir)
if err != nil {
if os.IsNotExist(err) {
return []BackupInfo{}, nil
}
return nil, err
}
out := []BackupInfo{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, BackupInfo{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
return out, nil
}
func (m *Manager) pruneBackupsLocked(keep int) {
backups, err := m.listBackupsLocked()
if err != nil || len(backups) <= keep {
return
}
for _, b := range backups[keep:] {
_ = os.Remove(filepath.Join(m.backupDir, b.Name))
}
}
// applyModelEnv writes the model env vars for the roles the provider
// defines and removes the ones it does not.
func applyModelEnv(env map[string]string, models config.Models) {
main := firstNonEmpty(models.Sonnet, models.Default)
setOrDelete(env, "ANTHROPIC_MODEL", main)
setOrDelete(env, "ANTHROPIC_MODEL_NAME", main)
setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", firstNonEmpty(models.Sonnet, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", firstNonEmpty(models.Sonnet, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", firstNonEmpty(models.Haiku, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", firstNonEmpty(models.Haiku, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", firstNonEmpty(models.Opus, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", firstNonEmpty(models.Opus, models.Default))
}
func setOrDelete(env map[string]string, key, value string) {
if value == "" {
delete(env, key)
return
}
env[key] = value
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+200
View File
@@ -0,0 +1,200 @@
package claudecfg
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
const originalSettings = `{
"theme": "dark",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-official",
"ANTHROPIC_BASE_URL": "https://official.example.com",
"DISABLE_AUTOUPDATER": "1"
}
}
`
func newManager(t *testing.T) (*Manager, string) {
t.Helper()
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.json")
if err := os.WriteFile(settingsPath, []byte(originalSettings), 0o600); err != nil {
t.Fatalf("write settings: %v", err)
}
return New(settingsPath, filepath.Join(dir, "backups")), settingsPath
}
func readSettings(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("parse settings: %v", err)
}
return out
}
func TestApplyPreservesUnknownKeys(t *testing.T) {
mgr, path := newManager(t)
if _, err := mgr.Apply(ApplyOptions{
BaseURL: "http://127.0.0.1:8787",
Token: "crl_test",
Models: config.Models{Sonnet: "ds-chat", Haiku: "ds-haiku", Opus: "ds-opus", Default: "ds-chat"},
}); err != nil {
t.Fatalf("Apply: %v", err)
}
settings := readSettings(t, path)
if settings["theme"] != "dark" {
t.Errorf("theme lost: %v", settings["theme"])
}
env := settings["env"].(map[string]any)
if env["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787" {
t.Errorf("base url = %v", env["ANTHROPIC_BASE_URL"])
}
if env["ANTHROPIC_AUTH_TOKEN"] != "crl_test" {
t.Errorf("relay token = %v", env["ANTHROPIC_AUTH_TOKEN"])
}
if env["DISABLE_AUTOUPDATER"] != "1" {
t.Errorf("unrelated env key lost: %v", env)
}
if env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] != "ds-haiku" {
t.Errorf("haiku model = %v", env["ANTHROPIC_DEFAULT_HAIKU_MODEL"])
}
if env["ANTHROPIC_MODEL"] != "ds-chat" {
t.Errorf("main model = %v", env["ANTHROPIC_MODEL"])
}
}
func TestApplyCreatesBackupWithOriginalContent(t *testing.T) {
mgr, _ := newManager(t)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
backups, err := mgr.Backups()
if err != nil {
t.Fatalf("Backups: %v", err)
}
if len(backups) != 1 {
t.Fatalf("backups = %d, want 1", len(backups))
}
data, err := os.ReadFile(filepath.Join(mgr.backupDir, backups[0].Name))
if err != nil {
t.Fatalf("read backup: %v", err)
}
settings := map[string]any{}
if err := json.Unmarshal(data, &settings); err != nil {
t.Fatalf("parse backup: %v", err)
}
env := settings["env"].(map[string]any)
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
t.Errorf("backup holds post-apply content: %v", env)
}
}
func TestApplyIsIdempotent(t *testing.T) {
mgr, path := newManager(t)
opts := ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "ds-chat"}}
if _, err := mgr.Apply(opts); err != nil {
t.Fatalf("first Apply: %v", err)
}
afterFirst := readSettings(t, path)
if _, err := mgr.Apply(opts); err != nil {
t.Fatalf("second Apply: %v", err)
}
afterSecond := readSettings(t, path)
firstEnv, _ := json.Marshal(afterFirst["env"])
secondEnv, _ := json.Marshal(afterSecond["env"])
if string(firstEnv) != string(secondEnv) {
t.Errorf("env changed on re-apply:\n%s\n%s", firstEnv, secondEnv)
}
}
func TestRestore(t *testing.T) {
mgr, path := newManager(t)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
name, err := mgr.Restore()
if err != nil {
t.Fatalf("Restore: %v", err)
}
if name == "" {
t.Error("Restore returned no backup name")
}
env := readSettings(t, path)["env"].(map[string]any)
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
t.Errorf("base url after restore = %v", env["ANTHROPIC_BASE_URL"])
}
if env["ANTHROPIC_AUTH_TOKEN"] != "sk-official" {
t.Errorf("token after restore = %v", env["ANTHROPIC_AUTH_TOKEN"])
}
}
func TestRestoreWithoutBackupFails(t *testing.T) {
mgr, _ := newManager(t)
if _, err := mgr.Restore(); err == nil {
t.Fatal("Restore succeeded without backups")
}
}
func TestApplyModelRoles(t *testing.T) {
mgr, path := newManager(t)
// Only a default: every role falls back to it.
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "only-default"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
env := readSettings(t, path)["env"].(map[string]any)
for _, key := range []string{
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
} {
if env[key] != "only-default" {
t.Errorf("%s = %v, want only-default", key, env[key])
}
}
// Empty roles remove the keys again.
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Sonnet: "s"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
env = readSettings(t, path)["env"].(map[string]any)
if _, ok := env["ANTHROPIC_DEFAULT_OPUS_MODEL"]; ok {
t.Error("opus key should be removed when the provider has no opus model")
}
}
func TestApplyCreatesMissingSettingsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
mgr := New(path, filepath.Join(dir, "backups"))
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "m"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings.json not created: %v", err)
}
}
func TestBackupsReturnsEmptySliceNotNil(t *testing.T) {
dir := t.TempDir()
mgr := New(filepath.Join(dir, "settings.json"), filepath.Join(dir, "backups"))
backups, err := mgr.Backups()
if err != nil {
t.Fatalf("Backups: %v", err)
}
if backups == nil {
t.Fatal("Backups returned nil, which marshals to JSON null and breaks the UI")
}
if len(backups) != 0 {
t.Errorf("backups = %v, want empty", backups)
}
}
+240
View File
@@ -0,0 +1,240 @@
// Package config owns CrossRelay's own persisted state: the provider list,
// the active selections, local gateway settings and UI preferences.
package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// Version is the config.json schema version.
const Version = 1
// Listener modes for the local gateway.
const (
PortModeAuto = "auto"
PortModeFixed = "fixed"
)
// Supported UI languages.
const (
LanguageChinese = "zh-CN"
LanguageEnglish = "en-US"
)
// Models maps requested model names onto upstream model names. The role
// fields are matched by keyword (a request mentioning "haiku" uses Haiku);
// Extra holds exact requested-model overrides and wins over everything.
type Models struct {
Default string `json:"default,omitempty"`
Sonnet string `json:"sonnet,omitempty"`
Haiku string `json:"haiku,omitempty"`
Opus string `json:"opus,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// Provider is one upstream Chat Completions endpoint.
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
Preset string `json:"preset,omitempty"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models Models `json:"models"`
EnabledForClaude bool `json:"enabled_for_claude"`
CreatedAt time.Time `json:"created_at"`
}
// Gateway holds local listener settings.
type Gateway struct {
PortMode string `json:"port_mode"`
FixedPort int `json:"fixed_port,omitempty"`
}
// Config is the whole persisted configuration.
type Config struct {
Version int `json:"version"`
RelayToken string `json:"relay_token"`
Providers []Provider `json:"providers"`
ActiveClaudeID string `json:"active_claude_id,omitempty"`
Gateway Gateway `json:"gateway"`
Language string `json:"language"`
}
// ResolveModel maps the model name Claude Code asked for onto the model
// name the upstream provider should receive.
func (p *Provider) ResolveModel(requested string) string {
if p == nil {
return requested
}
if requested == "" {
return firstNonEmpty(p.Models.Default, requested)
}
// Already an upstream model name (CrossRelay wrote it into the env).
for _, m := range []string{p.Models.Default, p.Models.Sonnet, p.Models.Haiku, p.Models.Opus} {
if m != "" && m == requested {
return requested
}
}
if m, ok := p.Models.Extra[requested]; ok && m != "" {
return m
}
lower := strings.ToLower(requested)
switch {
case strings.Contains(lower, "haiku"):
return firstNonEmpty(p.Models.Haiku, p.Models.Default, requested)
case strings.Contains(lower, "opus"):
return firstNonEmpty(p.Models.Opus, p.Models.Default, requested)
case strings.Contains(lower, "sonnet"):
return firstNonEmpty(p.Models.Sonnet, p.Models.Default, requested)
}
return firstNonEmpty(p.Models.Default, requested)
}
func (p *Provider) clone() *Provider {
if p == nil {
return nil
}
out := *p
if p.Models.Extra != nil {
out.Models.Extra = make(map[string]string, len(p.Models.Extra))
for k, v := range p.Models.Extra {
out.Models.Extra[k] = v
}
}
return &out
}
func (m *Models) normalize() {
m.Default = strings.TrimSpace(m.Default)
m.Sonnet = strings.TrimSpace(m.Sonnet)
m.Haiku = strings.TrimSpace(m.Haiku)
m.Opus = strings.TrimSpace(m.Opus)
for k, v := range m.Extra {
key := strings.TrimSpace(k)
val := strings.TrimSpace(v)
if key == "" || val == "" {
delete(m.Extra, k)
continue
}
if key != k || val != v {
delete(m.Extra, k)
m.Extra[key] = val
}
}
if len(m.Extra) == 0 {
m.Extra = nil
}
}
// DefaultConfig returns a fresh configuration with a new relay token.
func DefaultConfig() *Config {
return &Config{
Version: Version,
RelayToken: NewRelayToken(),
Providers: []Provider{},
Gateway: Gateway{PortMode: PortModeAuto},
Language: LanguageChinese,
}
}
func (c *Config) normalize() {
if c.Version == 0 {
c.Version = Version
}
if strings.TrimSpace(c.RelayToken) == "" {
c.RelayToken = NewRelayToken()
}
if c.Providers == nil {
c.Providers = []Provider{}
}
for i := range c.Providers {
c.Providers[i].Models.normalize()
}
switch c.Gateway.PortMode {
case PortModeAuto, PortModeFixed:
default:
c.Gateway.PortMode = PortModeAuto
}
if c.Gateway.PortMode == PortModeFixed && (c.Gateway.FixedPort < 1 || c.Gateway.FixedPort > 65535) {
c.Gateway.PortMode = PortModeAuto
c.Gateway.FixedPort = 0
}
if c.Language != LanguageChinese && c.Language != LanguageEnglish {
c.Language = LanguageChinese
}
}
func (c *Config) clone() *Config {
out := *c
out.Providers = make([]Provider, len(c.Providers))
for i, p := range c.Providers {
out.Providers[i] = *p.clone()
}
return &out
}
func newProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name is required")
}
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return nil, errors.New("base URL is required")
}
u, err := url.Parse(baseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("base URL must be an http(s) URL: %q", baseURL)
}
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, errors.New("API key is required")
}
models.normalize()
return &Provider{
ID: newID("p_"),
Name: name,
Preset: strings.TrimSpace(preset),
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Models: models,
CreatedAt: time.Now().UTC(),
}, nil
}
// NewRelayToken generates the local token Claude Code must present.
func NewRelayToken() string {
return "crl_" + randHex(24)
}
func newID(prefix string) string {
return prefix + randHex(12)
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func marshalConfig(c *Config) ([]byte, error) {
return json.MarshalIndent(c, "", " ")
}
+146
View File
@@ -0,0 +1,146 @@
package config
import (
"strings"
"testing"
)
func TestResolveModel(t *testing.T) {
p := &Provider{
Models: Models{
Default: "ds-chat",
Sonnet: "ds-chat",
Haiku: "ds-haiku",
Opus: "ds-opus",
Extra: map[string]string{"claude-3-5-haiku-20241022": "ds-fast"},
},
}
cases := []struct {
requested string
want string
}{
{"claude-sonnet-4-5-20250929", "ds-chat"},
{"claude-3-5-haiku-20241022", "ds-fast"}, // exact Extra match wins
{"some-haiku-model", "ds-haiku"}, // keyword match
{"claude-opus-4-1", "ds-opus"},
{"ds-chat", "ds-chat"}, // already an upstream name
{"unknown-model", "ds-chat"},
{"", "ds-chat"},
}
for _, tc := range cases {
if got := p.ResolveModel(tc.requested); got != tc.want {
t.Errorf("ResolveModel(%q) = %q, want %q", tc.requested, got, tc.want)
}
}
}
func TestResolveModelWithoutDefaults(t *testing.T) {
p := &Provider{Models: Models{Sonnet: "kimi-k2"}}
if got := p.ResolveModel("claude-3-7-sonnet"); got != "kimi-k2" {
t.Errorf("sonnet request = %q, want kimi-k2", got)
}
// No default configured: unknown models pass through unchanged.
if got := p.ResolveModel("weird-model"); got != "weird-model" {
t.Errorf("unknown request = %q, want passthrough", got)
}
}
func TestStoreRoundTrip(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !strings.HasPrefix(store.Config().RelayToken, "crl_") {
t.Fatalf("relay token = %q, want crl_ prefix", store.Config().RelayToken)
}
if store.Config().Language != LanguageChinese {
t.Fatalf("default language = %q", store.Config().Language)
}
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat", Sonnet: "deepseek-chat", Haiku: "deepseek-chat", Opus: "deepseek-chat"})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateClaude(p.ID); err != nil {
t.Fatalf("ActivateClaude: %v", err)
}
reloaded, err := Load()
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := reloaded.ActiveClaude(); got == nil || got.Name != "DeepSeek" {
t.Fatalf("active provider after reload = %+v", got)
}
if len(reloaded.Config().Providers) != 1 {
t.Fatalf("providers after reload = %d", len(reloaded.Config().Providers))
}
}
func TestAddProviderValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
cases := []struct {
name, baseURL, apiKey string
}{
{"", "https://x.com/v1", "sk"},
{"p", "", "sk"},
{"p", "ftp://x.com", "sk"},
{"p", "https://x.com/v1", ""},
}
for _, tc := range cases {
if _, err := store.AddProvider(tc.name, "", tc.baseURL, tc.apiKey, Models{}); err == nil {
t.Errorf("AddProvider(%q, %q, %q) succeeded, want error", tc.name, tc.baseURL, tc.apiKey)
}
}
}
func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"})
updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"})
if err != nil {
t.Fatalf("UpdateProvider: %v", err)
}
if updated.APIKey != "sk-secret" {
t.Errorf("api key = %q, want sk-secret", updated.APIKey)
}
if updated.Name != "p2" || updated.Models.Default != "m2" {
t.Errorf("update lost fields: %+v", updated)
}
if updated.BaseURL != "https://x.com/v1" {
t.Errorf("base url = %q, want trailing slash trimmed", updated.BaseURL)
}
}
func TestDeleteProviderClearsActive(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{})
store.ActivateClaude(p.ID)
if err := store.DeleteProvider(p.ID); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if store.ActiveClaude() != nil {
t.Error("active provider survived deletion")
}
}
func TestSetGatewayValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 8787}); err != nil {
t.Fatalf("SetGateway: %v", err)
}
if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 0}); err == nil {
t.Error("fixed port 0 accepted")
}
if err := store.SetGateway(Gateway{PortMode: "bogus"}); err == nil {
t.Error("bogus port mode accepted")
}
if err := store.SetLanguage("fr"); err == nil {
t.Error("unknown language accepted")
}
}
+249
View File
@@ -0,0 +1,249 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Store loads and persists the configuration file. All methods are safe
// for concurrent use.
type Store struct {
mu sync.Mutex
path string
cfg *Config
}
// Dir returns the CrossRelay data directory. CROSSRELAY_HOME overrides
// the default location (~/.crossrelay), which tests rely on.
func Dir() string {
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_HOME")); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ".crossrelay"
}
return filepath.Join(home, ".crossrelay")
}
// Path returns the config file location.
func Path() string { return filepath.Join(Dir(), "config.json") }
// Load reads the config file, creating defaults when it does not exist.
func Load() (*Store, error) {
s := &Store{path: Path()}
data, err := os.ReadFile(s.path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
s.cfg = DefaultConfig()
return s, s.save()
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", s.path, err)
}
cfg.normalize()
s.cfg = &cfg
return s, nil
}
// Config returns a snapshot of the configuration.
func (s *Store) Config() *Config {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg.clone()
}
// FindProvider returns a copy of the provider with the given id.
func (s *Store) FindProvider(id string) *Provider {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
return s.cfg.Providers[i].clone()
}
}
return nil
}
// ActiveClaude returns the provider currently applied to Claude Code.
func (s *Store) ActiveClaude() *Provider {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveClaudeID == "" {
return nil
}
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == s.cfg.ActiveClaudeID {
return s.cfg.Providers[i].clone()
}
}
return nil
}
// AddProvider validates and appends a provider.
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, err := newProvider(name, preset, baseURL, apiKey, models)
if err != nil {
return nil, err
}
s.cfg.Providers = append(s.cfg.Providers, *p)
if err := s.saveLocked(); err != nil {
return nil, err
}
return p.clone(), nil
}
// UpdateProvider replaces a provider's editable fields. An empty apiKey
// keeps the stored key so edits never wipe it by accident.
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id)
}
existing := s.cfg.Providers[idx]
if apiKey == "" {
apiKey = existing.APIKey
}
updated, err := newProvider(name, preset, baseURL, apiKey, models)
if err != nil {
return nil, err
}
updated.ID = existing.ID
updated.EnabledForClaude = existing.EnabledForClaude
updated.CreatedAt = existing.CreatedAt
s.cfg.Providers[idx] = *updated
if err := s.saveLocked(); err != nil {
return nil, err
}
return updated.clone(), nil
}
// DeleteProvider removes a provider and clears it when active.
func (s *Store) DeleteProvider(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("provider %q not found", id)
}
s.cfg.Providers = append(s.cfg.Providers[:idx], s.cfg.Providers[idx+1:]...)
if s.cfg.ActiveClaudeID == id {
s.cfg.ActiveClaudeID = ""
}
return s.saveLocked()
}
// ActivateClaude marks a provider as the one written into Claude Code's
// settings and records it as active.
func (s *Store) ActivateClaude(id string) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id)
}
s.cfg.Providers[idx].EnabledForClaude = true
s.cfg.ActiveClaudeID = id
if err := s.saveLocked(); err != nil {
return nil, err
}
return s.cfg.Providers[idx].clone(), nil
}
// ClearActiveClaude forgets the active provider without touching the
// provider list (used after restoring the official settings).
func (s *Store) ClearActiveClaude() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveClaudeID == "" {
return nil
}
s.cfg.ActiveClaudeID = ""
return s.saveLocked()
}
// SetGateway updates listener settings.
func (s *Store) SetGateway(g Gateway) error {
g.PortMode = strings.TrimSpace(g.PortMode)
switch g.PortMode {
case PortModeAuto:
g.FixedPort = 0
case PortModeFixed:
if g.FixedPort < 1 || g.FixedPort > 65535 {
return errors.New("fixed port must be between 1 and 65535")
}
default:
return errors.New("port mode must be \"auto\" or \"fixed\"")
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.Gateway = g
return s.saveLocked()
}
// SetLanguage updates the UI language.
func (s *Store) SetLanguage(lang string) error {
if lang != LanguageChinese && lang != LanguageEnglish {
return errors.New("language must be zh-CN or en-US")
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.Language = lang
return s.saveLocked()
}
func (s *Store) save() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.saveLocked()
}
func (s *Store) saveLocked() error {
data, err := marshalConfig(s.cfg)
if err != nil {
return err
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
+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)
}
+56
View File
@@ -0,0 +1,56 @@
// Package presets holds the built-in upstream provider templates.
package presets
import "gitea.rutua.cn/kate522/CrossRelay/internal/config"
// Preset is a ready-made provider template. Model names reflect each
// vendor's catalog as of 2026-10 and can be edited per provider.
type Preset struct {
Key string `json:"key"`
BaseURL string `json:"base_url"`
Models config.Models `json:"models"`
}
// All lists the built-in presets.
var All = []Preset{
{
Key: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
Models: config.Models{
Default: "deepseek-chat",
Sonnet: "deepseek-chat",
Haiku: "deepseek-chat",
Opus: "deepseek-chat",
},
},
{
Key: "moonshot",
BaseURL: "https://api.moonshot.cn/v1",
Models: config.Models{
Default: "kimi-k2-0905-preview",
Sonnet: "kimi-k2-0905-preview",
Haiku: "kimi-k2-0905-preview",
Opus: "kimi-k2-0905-preview",
},
},
{
Key: "zhipu",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Models: config.Models{
Default: "glm-4.6",
Sonnet: "glm-4.6",
Haiku: "glm-4.6",
Opus: "glm-4.6",
},
},
}
// ByKey looks up a preset by its key.
func ByKey(key string) (Preset, bool) {
for _, p := range All {
if p.Key == key {
return p, true
}
}
return Preset{}, false
}
+4486
View File
File diff suppressed because it is too large. Load diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "crossrelay",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tanstack/react-query": "^5.62.7",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-http": "^2.2.0",
"clsx": "^2.1.1",
"i18next": "^24.2.2",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.1.0",
"react-router-dom": "^7.1.1",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.2.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^3.2.7"
}
}
+25
View File
@@ -0,0 +1,25 @@
# Builds the CrossRelay gateway binary into bin/.
# Usage: powershell -File scripts/build-gateway.ps1 [-GOOS windows|darwin|linux] [-GOARCH amd64|arm64]
param(
[string]$GOOS = "windows",
[string]$GOARCH = "amd64"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$binDir = Join-Path $repoRoot "bin"
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
$exe = if ($GOOS -eq "windows") { "gateway.exe" } else { "gateway" }
$out = Join-Path $binDir "$GOOS-$GOARCH-$exe"
$env:GOOS = $GOOS
$env:GOARCH = $GOARCH
go build -o $out ./cmd/gateway
# Keep a stable name for the current platform so the Tauri app finds it.
$stable = Join-Path $binDir $exe
Copy-Item $out $stable -Force
Write-Host "built $out (also $stable)"
+172
View File
@@ -0,0 +1,172 @@
// Generates the Tauri app icons (PNG + ICO) with a hand-drawn "relay"
// glyph: two arrows passing each other on a teal tile.
// Run: node scripts/gen-icons.mjs
import zlib from "node:zlib";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const outDir = path.join(
path.dirname(path.dirname(fileURLToPath(import.meta.url))),
"src-tauri",
"icons",
);
function crc32(buf) {
let c = ~0;
for (let i = 0; i < buf.length; i++) {
c ^= buf[i];
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function png(width, height, rgba) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter: none
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
const TEAL = [13, 148, 136]; // #0d9488
const TEAL_DARK = [15, 118, 110]; // #0f766e
const WHITE = [255, 255, 255];
function drawRgba(size) {
const rgba = Buffer.alloc(size * size * 4);
const radius = size * 0.22;
const center = (size - 1) / 2;
const bar = Math.max(1, Math.round(size * 0.075));
const headLong = size * 0.17;
const headShort = size * 0.11;
const gap = size * 0.1;
const armLen = size * 0.3;
const inTile = (x, y) => {
const dx = Math.min(x, size - 1 - x);
const dy = Math.min(y, size - 1 - y);
if (dx >= radius || dy >= radius) return true;
const cx = dx - (radius - 1);
const cy = dy - (radius - 1);
return cx * cx + cy * cy <= 1;
};
const inArrow = (x, y, dir) => {
// dir = 1: arrow points right (top), -1: points left (bottom)
const cy = dir === 1 ? center - gap : center + gap;
const tipX = center + dir * (armLen + headLong);
const baseX = center - dir * armLen;
// shaft
if (Math.abs(y - cy) <= bar / 2 && x >= Math.min(baseX, tipX) && x <= Math.max(baseX, tipX)) {
return true;
}
// head: triangle growing away from the shaft
const headStart = dir === 1 ? tipX - headLong : tipX;
const headEnd = dir === 1 ? tipX : tipX + headLong;
if (x < Math.min(headStart, headEnd) || x > Math.max(headStart, headEnd)) return false;
const t = dir === 1 ? (x - headStart) / headLong : (headEnd - x) / headLong;
return Math.abs(y - cy) <= (bar / 2) * (1 - t) + headShort * t;
};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
if (!inTile(x, y)) {
rgba[i + 3] = 0;
continue;
}
// subtle vertical gradient on the tile
const mix = y / size;
const base = TEAL.map((c, k) => Math.round(c * (1 - mix) + TEAL_DARK[k] * mix));
let color = base;
if (inArrow(x, y, 1) || inArrow(x, y, -1)) color = WHITE;
rgba[i] = color[0];
rgba[i + 1] = color[1];
rgba[i + 2] = color[2];
rgba[i + 3] = 255;
}
}
return rgba;
}
function drawIcon(size) {
return png(size, size, drawRgba(size));
}
// Converts an RGBA buffer (top-down) into a bottom-up BGRA DIB plus a
// transparent AND mask, the classic BMP-in-ICO layout RC.exe expects.
function dibFromRgba(size, rgba) {
const header = Buffer.alloc(40);
header.writeUInt32LE(40, 0);
header.writeInt32LE(size, 4);
header.writeInt32LE(size * 2, 8); // height includes the AND mask
header.writeUInt16LE(1, 12);
header.writeUInt16LE(32, 14);
const pixels = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) {
const srcRow = (size - 1 - y) * size * 4;
rgba.copy(pixels, y * size * 4, srcRow, srcRow + size * 4);
for (let x = 0; x < size; x++) {
const i = y * size * 4 + x * 4;
const r = pixels[i];
pixels[i] = pixels[i + 2];
pixels[i + 2] = r; // RGBA -> BGRA
}
}
const maskRowBytes = Math.ceil(size / 8 / 4) * 4 || 4;
const mask = Buffer.alloc(maskRowBytes * size); // all zero: fully opaque
return Buffer.concat([header, pixels, mask]);
}
function ico(entries) {
// entries: [{ size, dib }]
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(entries.length, 4);
let offset = 6 + entries.length * 16;
const dirEntries = entries.map(({ size, dib }) => {
const entry = Buffer.alloc(16);
entry[0] = size >= 256 ? 0 : size;
entry[1] = size >= 256 ? 0 : size;
entry[2] = 0;
entry[3] = 0;
entry.writeUInt16LE(1, 4);
entry.writeUInt16LE(32, 6);
entry.writeUInt32LE(dib.length, 8);
entry.writeUInt32LE(offset, 12);
offset += dib.length;
return entry;
});
return Buffer.concat([header, ...dirEntries, ...entries.map((e) => e.dib)]);
}
fs.mkdirSync(outDir, { recursive: true });
const sizes = [16, 32, 48, 256];
const dibs = sizes.map((size) => ({ size, dib: dibFromRgba(size, drawRgba(size)) }));
fs.writeFileSync(path.join(outDir, "32x32.png"), drawIcon(32));
fs.writeFileSync(path.join(outDir, "128x128.png"), drawIcon(128));
fs.writeFileSync(path.join(outDir, "128x128@2x.png"), drawIcon(256));
fs.writeFileSync(path.join(outDir, "icon.ico"), ico(dibs));
console.log(`icons written to ${outDir}`);
+4914
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "crossrelay"
version = "0.1.0"
description = "CrossRelay"
authors = ["kate522"]
edition = "2021"
[lib]
name = "crossrelay_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-http = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for CrossRelay: local gateway HTTP access.",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
{
"identifier": "http:default",
"allow": [
{ "url": "http://127.0.0.1:*" },
{ "url": "http://localhost:*" }
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+205
View File
@@ -0,0 +1,205 @@
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::Serialize;
use tauri::{Emitter, Manager};
/// Shared gateway process state.
struct GatewayInner {
child: Mutex<Option<Child>>,
stdin: Mutex<Option<ChildStdin>>,
port: Mutex<Option<u16>>,
}
impl GatewayInner {
fn kill(&self) {
// Closing the stdin pipe first lets the gateway shut down
// gracefully; kill() then guarantees it is gone.
drop(self.stdin.lock().unwrap().take());
if let Some(mut child) = self.child.lock().unwrap().take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
struct GatewayState(Arc<GatewayInner>);
#[derive(Serialize)]
pub struct GatewayStatus {
running: bool,
port: Option<u16>,
pid: Option<u32>,
}
/// Waits (up to 20s) until the gateway reports its port, then returns it.
#[tauri::command]
fn gateway_port(state: tauri::State<'_, GatewayState>) -> Result<Option<u16>, String> {
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(port) = *state.0.port.lock().unwrap() {
return Ok(Some(port));
}
if state.0.child.lock().unwrap().is_none() {
return Ok(None);
}
if Instant::now() > deadline {
return Ok(None);
}
std::thread::sleep(Duration::from_millis(100));
}
}
#[tauri::command]
fn gateway_status(state: tauri::State<'_, GatewayState>) -> GatewayStatus {
let mut guard = state.0.child.lock().unwrap();
let (running, pid) = match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(_)) => {
*guard = None;
(false, None)
}
Ok(None) => (true, Some(child.id())),
Err(_) => (false, None),
},
None => (false, None),
};
let port = *state.0.port.lock().unwrap();
GatewayStatus { running, port, pid }
}
/// Locates the gateway binary: CROSSRELAY_GATEWAY_BIN wins, then a set of
/// paths relative to the app executable (dev builds, packaged app).
fn gateway_binary() -> Option<PathBuf> {
if let Ok(path) = std::env::var("CROSSRELAY_GATEWAY_BIN") {
if !path.trim().is_empty() {
return Some(PathBuf::from(path));
}
}
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let names: &[&str] = if cfg!(windows) {
&["gateway.exe", "gateway"]
} else {
&["gateway"]
};
let candidates = [
dir.join("gateway"),
dir.join("bin").join("gateway"),
// Dev layout: src-tauri/target/{debug,release} -> repo/bin
dir.join("..").join("..").join("..").join("bin").join("gateway"),
dir.join("..").join("bin").join("gateway"),
];
for candidate in candidates {
for name in names {
let path = if candidate.extension().is_some() {
candidate.clone()
} else {
candidate.with_file_name(name)
};
if path.exists() {
return Some(path);
}
}
}
None
}
fn spawn_gateway(handle: &tauri::AppHandle, inner: &Arc<GatewayInner>) {
let Some(bin) = gateway_binary() else {
eprintln!("crossrelay: gateway binary not found; set CROSSRELAY_GATEWAY_BIN");
return;
};
eprintln!("crossrelay: starting gateway {}", bin.display());
let mut command = Command::new(&bin);
command
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.stdin(Stdio::piped())
.env("CROSSRELAY_WATCH_PARENT", "1");
match command.spawn() {
Ok(mut child) => {
let stdout = child.stdout.take();
*inner.stdin.lock().unwrap() = child.stdin.take();
*inner.child.lock().unwrap() = Some(child);
if let Some(stdout) = stdout {
let inner = Arc::clone(inner);
let handle = handle.clone();
std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().flatten() {
if let Some(rest) = line.strip_prefix("CROSSRELAY_LISTENING ") {
if let Some(port_str) = rest.rsplit(':').next() {
if let Ok(port) = port_str.trim().parse::<u16>() {
*inner.port.lock().unwrap() = Some(port);
let _ = handle.emit("gateway-ready", port);
continue;
}
}
}
eprintln!("[gateway] {}", line);
}
// Gateway exited: forget the port.
*inner.port.lock().unwrap() = None;
});
}
}
Err(err) => {
eprintln!("crossrelay: failed to start gateway: {}", err);
}
}
}
/// Removes the gateway state file so a stale port never lingers.
fn cleanup_state_file() {
let mut dir = std::env::var("CROSSRELAY_HOME")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from);
if dir.is_none() {
if let Ok(home) = std::env::var("USERPROFILE") {
dir = Some(PathBuf::from(home).join(".crossrelay"));
}
}
if let Some(dir) = dir {
let _ = std::fs::remove_file(dir.join("state.json"));
}
}
pub fn run() {
let inner = Arc::new(GatewayInner {
child: Mutex::new(None),
stdin: Mutex::new(None),
port: Mutex::new(None),
});
tauri::Builder::default()
.manage(GatewayState(Arc::clone(&inner)))
.plugin(tauri_plugin_http::init())
.setup(move |app| {
spawn_gateway(app.handle(), &inner);
Ok(())
})
.on_window_event(move |window, event| {
if let tauri::WindowEvent::Destroyed = event {
if let Some(state) = window.try_state::<GatewayState>() {
state.0.kill();
cleanup_state_file();
}
}
})
.invoke_handler(tauri::generate_handler![gateway_port, gateway_status])
.build(tauri::generate_context!())
.expect("error while building CrossRelay")
.run(|app_handle, event| {
if let tauri::RunEvent::Exit = event {
if let Some(state) = app_handle.try_state::<GatewayState>() {
state.0.kill();
cleanup_state_file();
}
}
});
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
crossrelay_lib::run()
}
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CrossRelay",
"version": "0.1.0",
"identifier": "cn.rutua.crossrelay",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "CrossRelay",
"width": 1120,
"height": 760,
"minWidth": 960,
"minHeight": 640
}
],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:* http://localhost:*"
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import i18n from "./i18n";
import { ToastProvider } from "./components/toast";
import { Layout } from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Providers from "./pages/Providers";
import SettingsPage from "./pages/SettingsPage";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 2000, refetchOnWindowFocus: false },
},
});
export default function App({ initialLanguage }: { initialLanguage?: string }) {
useEffect(() => {
if (initialLanguage && i18n.language !== initialLanguage) {
void i18n.changeLanguage(initialLanguage);
}
}, [initialLanguage]);
return (
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<ToastProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/providers" element={<Providers />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</ToastProvider>
</I18nextProvider>
</QueryClientProvider>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { invoke } from "@tauri-apps/api/core";
import { fetch } from "@tauri-apps/plugin-http";
import { SUPPORTED_LANGUAGES, type Language } from "./i18n";
export interface Bootstrap {
port: number | null;
language: Language;
}
// Resolves the gateway port from the Tauri shell and loads the saved UI
// language before the first render.
export async function bootstrap(): Promise<Bootstrap> {
let port: number | null = null;
try {
port = await invoke<number | null>("gateway_port");
} catch {
port = null;
}
let language: Language = "zh-CN";
if (port) {
try {
const response = await fetch(`http://127.0.0.1:${port}/api/settings`);
if (response.ok) {
const text = await response.text();
const parsed = text ? JSON.parse(text) : {};
if (SUPPORTED_LANGUAGES.includes(parsed.language)) {
language = parsed.language;
}
}
} catch {
// Gateway unreachable: fall back to the default language.
}
}
return { port, language };
}
+67
View File
@@ -0,0 +1,67 @@
import { NavLink, Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Gauge, Server, Settings as SettingsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsMutation, useSettings } from "@/lib/queries";
import i18n from "@/i18n";
import { Button } from "@/components/ui/button";
const navItems = [
{ to: "/", key: "nav.dashboard", icon: Gauge },
{ to: "/providers", key: "nav.providers", icon: Server },
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
];
export function Layout() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const mutation = useSettingsMutation();
const language = settings?.language ?? i18n.language;
const toggleLanguage = () => {
const next = language === "zh-CN" ? "en-US" : "zh-CN";
void i18n.changeLanguage(next);
mutation.mutate({ language: next });
};
return (
<div className="flex h-screen bg-zinc-50 text-zinc-900">
<aside className="flex w-52 shrink-0 flex-col border-r border-zinc-200 bg-white">
<div className="px-4 py-4">
<div className="text-base font-semibold tracking-tight">CrossRelay</div>
<div className="text-xs text-zinc-500">{t("app.tagline")}</div>
</div>
<nav className="flex flex-col gap-0.5 px-2">
{navItems.map(({ to, key, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors",
isActive
? "bg-teal-50 text-teal-800"
: "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900",
)
}
>
<Icon size={16} aria-hidden />
{t(key)}
</NavLink>
))}
</nav>
<div className="mt-auto p-3">
<Button variant="outline" size="sm" className="w-full" onClick={toggleLanguage}>
{language === "zh-CN" ? "中文 / EN" : "EN / 中文"}
</Button>
</div>
</aside>
<main className="flex-1 overflow-auto">
<div className="mx-auto max-w-5xl p-6">
<Outlet />
</div>
</main>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface Toast {
id: number;
message: string;
tone: "success" | "error";
}
interface ToastContextValue {
toasts: Toast[];
show: (message: string, tone?: Toast["tone"]) => void;
}
const ToastContext = React.createContext<ToastContextValue | null>(null);
let nextId = 1;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Toast[]>([]);
const show = React.useCallback((message: string, tone: Toast["tone"] = "success") => {
const id = nextId++;
setToasts((current) => [...current, { id, message, tone }]);
window.setTimeout(() => {
setToasts((current) => current.filter((t) => t.id !== id));
}, 4000);
}, []);
const value = React.useMemo(() => ({ toasts, show }), [toasts, show]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-72 flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={cn(
"pointer-events-auto rounded-md px-3 py-2 text-sm shadow-md",
toast.tone === "success"
? "bg-emerald-600 text-white"
: "bg-red-600 text-white",
)}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = React.useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside ToastProvider");
return ctx;
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Tone = "neutral" | "success" | "warning" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-zinc-100 text-zinc-700",
success: "bg-emerald-50 text-emerald-700",
warning: "bg-amber-50 text-amber-700",
danger: "bg-red-50 text-red-700",
};
export function Badge({
tone = "neutral",
className,
...props
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: Tone }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
tones[tone],
className,
)}
{...props}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Variant = "default" | "outline" | "ghost" | "destructive";
type Size = "sm" | "md" | "icon";
const variants: Record<Variant, string> = {
default: "bg-teal-700 text-white hover:bg-teal-800",
outline: "border border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-50",
ghost: "text-zinc-700 hover:bg-zinc-100",
destructive: "bg-red-600 text-white hover:bg-red-700",
};
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-sm",
md: "h-9 px-4 text-sm",
icon: "h-9 w-9",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-1",
"disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
),
);
Button.displayName = "Button";
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-lg border border-zinc-200 bg-white shadow-sm", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1 p-5 pb-3", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-sm font-semibold text-zinc-900", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-xs text-zinc-500", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-5 pt-0", className)} {...props} />;
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900",
"placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600",
"disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label ref={ref} className={cn("text-xs font-medium text-zinc-600", className)} {...props} />
),
);
Label.displayName = "Label";
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Select = React.forwardRef<
HTMLSelectElement,
React.SelectHTMLAttributes<HTMLSelectElement>
>(({ className, ...props }, ref) => (
<select
ref={ref}
className={cn(
"h-9 w-full rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600",
className,
)}
{...props}
/>
));
Select.displayName = "Select";
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label: string;
id?: string;
}
export function Switch({ checked, onCheckedChange, label, id }: SwitchProps) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onCheckedChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-teal-700" : "bg-zinc-300",
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
/>
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn("w-full text-sm", className)} {...props} />;
}
export function THead({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("border-b border-zinc-200", className)} {...props} />;
}
export function TBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("divide-y divide-zinc-100", className)} {...props} />;
}
export function TR({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn("text-left", className)} {...props} />;
}
export function TH({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th className={cn("px-3 py-2 text-xs font-medium text-zinc-500", className)} {...props} />
);
}
export function TD({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("px-3 py-2 text-zinc-800", className)} {...props} />;
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { enUS, zhCN } from "./resources";
function flatten(value: unknown, prefix = ""): string[] {
if (typeof value !== "object" || value === null) return [prefix];
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
flatten(child, prefix ? `${prefix}.${key}` : key),
);
}
describe("i18n resources", () => {
it("zh-CN and en-US define the same keys", () => {
const zhKeys = flatten(zhCN.translation).sort();
const enKeys = flatten(enUS.translation).sort();
expect(zhKeys).toEqual(enKeys);
});
it("has no empty strings", () => {
for (const key of flatten(zhCN.translation)) {
const value = key
.split(".")
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], zhCN.translation);
expect(typeof value).toBe("string");
expect((value as string).length).toBeGreaterThan(0);
}
});
});
+19
View File
@@ -0,0 +1,19 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { enUS, zhCN } from "./resources";
export const SUPPORTED_LANGUAGES = ["zh-CN", "en-US"] as const;
export type Language = (typeof SUPPORTED_LANGUAGES)[number];
void i18n.use(initReactI18next).init({
resources: {
"zh-CN": zhCN,
"en-US": enUS,
},
lng: "zh-CN",
fallbackLng: "en-US",
interpolation: { escapeValue: false },
returnNull: false,
});
export default i18n;
+213
View File
@@ -0,0 +1,213 @@
export const zhCN = {
translation: {
app: {
name: "CrossRelay",
tagline: "Claude Code 与 Codex 的本地协议网关",
},
nav: {
dashboard: "仪表盘",
providers: "供应商",
settings: "设置",
},
common: {
save: "保存",
cancel: "取消",
delete: "删除",
edit: "编辑",
add: "新增",
activate: "激活",
restoring: "还原中",
restore: "还原官方配置",
loading: "加载中",
error: "出错了",
retry: "重试",
none: "无",
optional: "选填",
language: "语言",
actions: "操作",
saved: "已保存",
copyFailed: "操作失败",
yes: "是",
no: "否",
},
dashboard: {
title: "仪表盘",
gateway: "网关",
running: "运行中",
stopped: "已停止",
port: "端口",
uptime: "运行时长",
version: "版本",
activeProvider: "当前供应商",
noActiveProvider: "尚未激活任何供应商",
requests: "请求总数",
failed: "失败请求",
tokensIn: "输入 Token",
tokensOut: "输出 Token",
recentRequests: "最近请求",
quickApply: "写入 Claude 配置",
restoreDone: "已还原为官方配置",
tableTime: "时间",
tableModel: "模型",
tableStatus: "状态",
tableDuration: "耗时",
tableTokens: "Token",
emptyLogs: "暂无请求记录",
statsTitle: "统计",
},
providers: {
title: "供应商",
add: "新增供应商",
edit: "编辑供应商",
name: "名称",
baseUrl: "Base URL",
apiKey: "API Key",
preset: "预设",
presetCustom: "自定义",
models: "模型映射",
defaultModel: "默认模型",
sonnetModel: "Sonnet 模型",
haikuModel: "Haiku 模型",
opusModel: "Opus 模型",
extraMappings: "精确映射",
extraKey: "请求的模型名",
extraValue: "上游模型名",
addMapping: "添加一行",
activated: "已激活",
activate: "激活并写入",
empty: "还没有供应商,先添加一个",
validationName: "请填写名称",
validationBaseUrl: "请填写 http(s) Base URL",
validationApiKey: "请填写 API Key",
appliedEnv: "已写入 ~/.claude/settings.json 的环境变量",
created: "供应商已创建",
updated: "供应商已更新",
deleted: "供应商已删除",
deleteConfirm: "确定删除这个供应商吗?",
keyHint: "留空表示保持原有 Key 不变",
enabledClaude: "对 Claude Code 启用",
},
settings: {
title: "设置",
gateway: "网关",
portMode: "端口模式",
portAuto: "自动(随机端口)",
portFixed: "固定端口",
fixedPort: "固定端口号",
portRestartHint: "端口模式在网关重启后生效",
language: "界面语言",
backups: "配置备份",
backupsEmpty: "暂无备份",
restoreBackup: "还原",
restored: "已还原",
saveFailed: "保存失败",
},
},
};
export const enUS = {
translation: {
app: {
name: "CrossRelay",
tagline: "Local protocol gateway for Claude Code and Codex",
},
nav: {
dashboard: "Dashboard",
providers: "Providers",
settings: "Settings",
},
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
add: "Add",
activate: "Activate",
restoring: "Restoring",
restore: "Restore official config",
loading: "Loading",
error: "Something went wrong",
retry: "Retry",
none: "None",
optional: "optional",
language: "Language",
actions: "Actions",
saved: "Saved",
copyFailed: "Action failed",
yes: "Yes",
no: "No",
},
dashboard: {
title: "Dashboard",
gateway: "Gateway",
running: "Running",
stopped: "Stopped",
port: "Port",
uptime: "Uptime",
version: "Version",
activeProvider: "Active provider",
noActiveProvider: "No provider activated yet",
requests: "Requests",
failed: "Failed",
tokensIn: "Input tokens",
tokensOut: "Output tokens",
recentRequests: "Recent requests",
quickApply: "Write to Claude config",
restoreDone: "Official config restored",
tableTime: "Time",
tableModel: "Model",
tableStatus: "Status",
tableDuration: "Duration",
tableTokens: "Tokens",
emptyLogs: "No requests yet",
statsTitle: "Stats",
},
providers: {
title: "Providers",
add: "Add provider",
edit: "Edit provider",
name: "Name",
baseUrl: "Base URL",
apiKey: "API key",
preset: "Preset",
presetCustom: "Custom",
models: "Model mapping",
defaultModel: "Default model",
sonnetModel: "Sonnet model",
haikuModel: "Haiku model",
opusModel: "Opus model",
extraMappings: "Exact mappings",
extraKey: "Requested model",
extraValue: "Upstream model",
addMapping: "Add row",
activated: "Active",
activate: "Activate & apply",
empty: "No providers yet",
validationName: "Name is required",
validationBaseUrl: "An http(s) base URL is required",
validationApiKey: "API key is required",
appliedEnv: "Environment written to ~/.claude/settings.json",
created: "Provider created",
updated: "Provider updated",
deleted: "Provider deleted",
deleteConfirm: "Delete this provider?",
keyHint: "Leave empty to keep the current key",
enabledClaude: "Enabled for Claude Code",
},
settings: {
title: "Settings",
gateway: "Gateway",
portMode: "Port mode",
portAuto: "Automatic (random port)",
portFixed: "Fixed port",
fixedPort: "Fixed port number",
portRestartHint: "Port mode applies after the gateway restarts",
language: "Language",
backups: "Config backups",
backupsEmpty: "No backups yet",
restoreBackup: "Restore",
restored: "Restored",
saveFailed: "Save failed",
},
},
};
+19
View File
@@ -0,0 +1,19 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", ui-monospace, monospace;
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased;
font-family: var(--font-sans);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { fetch } from "@tauri-apps/plugin-http";
import type {
ApplyResult,
BackupInfo,
LogEntry,
Models,
Provider,
Settings,
Status,
} from "./types";
const FALLBACK_PORT = 8787;
function baseUrl(): string {
const port =
(window as unknown as { __CROSSRELAY_PORT__?: number }).__CROSSRELAY_PORT__ ?? FALLBACK_PORT;
return `http://127.0.0.1:${port}`;
}
export class ApiError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(baseUrl() + path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
} catch (err) {
throw new ApiError(`${err instanceof Error ? err.message : String(err)}`);
}
const text = await response.text();
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const body = JSON.parse(text);
if (body?.error?.message) message = body.error.message;
else if (typeof body?.error === "string") message = body.error;
} catch {
if (text) message = text;
}
throw new ApiError(message);
}
return (text ? JSON.parse(text) : null) as T;
}
export interface ProviderInput {
name: string;
preset?: string;
base_url: string;
api_key: string;
models: Models;
}
export const api = {
getStatus: () => request<Status>("/api/status"),
listProviders: () => request<{ items: Provider[] }>("/api/providers"),
createProvider: (input: ProviderInput) =>
request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: string, input: ProviderInput) =>
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: string) =>
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
applyProvider: (id: string) =>
request<ApplyResult>(`/api/providers/${id}/apply`, { method: "POST", body: "{}" }),
restore: () => request<{ restored_from: string }>("/api/restore", { method: "POST", body: "{}" }),
listBackups: () => request<{ items: BackupInfo[] }>("/api/backups"),
getSettings: () => request<Settings>("/api/settings"),
updateSettings: (patch: Partial<Settings>) =>
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
listLogs: () => request<{ items: LogEntry[] }>("/api/logs"),
};
+76
View File
@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ProviderInput } from "./api";
export const queryKeys = {
status: ["status"] as const,
providers: ["providers"] as const,
logs: ["logs"] as const,
backups: ["backups"] as const,
settings: ["settings"] as const,
};
export function useStatus() {
return useQuery({ queryKey: queryKeys.status, queryFn: api.getStatus, refetchInterval: 5000 });
}
export function useProviders() {
return useQuery({ queryKey: queryKeys.providers, queryFn: api.listProviders });
}
export function useLogs() {
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
}
export function useBackups() {
return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups });
}
export function useSettings() {
return useQuery({ queryKey: queryKeys.settings, queryFn: api.getSettings });
}
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
queryClient.invalidateQueries({ queryKey: queryKeys.backups }),
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
]);
}
export function useProviderMutations() {
const queryClient = useQueryClient();
const create = useMutation({
mutationFn: (input: ProviderInput) => api.createProvider(input),
onSuccess: () => invalidateAll(queryClient),
});
const update = useMutation({
mutationFn: ({ id, input }: { id: string; input: ProviderInput }) => api.updateProvider(id, input),
onSuccess: () => invalidateAll(queryClient),
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteProvider(id),
onSuccess: () => invalidateAll(queryClient),
});
const apply = useMutation({
mutationFn: (id: string) => api.applyProvider(id),
onSuccess: () => invalidateAll(queryClient),
});
return { create, update, remove, apply };
}
export function useRestore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.restore(),
onSuccess: () => invalidateAll(queryClient),
});
}
export function useSettingsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: Parameters<typeof api.updateSettings>[0]) => api.updateSettings(patch),
onSuccess: () => invalidateAll(queryClient),
});
}
+102
View File
@@ -0,0 +1,102 @@
export interface Models {
default?: string;
sonnet?: string;
haiku?: string;
opus?: string;
extra?: Record<string, string>;
}
export interface Provider {
id: string;
name: string;
preset?: string;
base_url: string;
api_key: string;
models: Models;
enabled_for_claude: boolean;
created_at: string;
}
export interface Stats {
requests_total: number;
requests_failed: number;
input_tokens: number;
output_tokens: number;
}
export interface ActiveProvider {
id: string;
name: string;
base_url: string;
models: Models;
}
export interface Status {
running: boolean;
version: string;
port: number;
uptime_sec: number;
active_claude_provider: ActiveProvider | null;
stats: Stats;
language: string;
port_mode: string;
fixed_port: number;
}
export interface LogEntry {
time: string;
method: string;
path: string;
model?: string;
status: number;
duration_ms: number;
input_tokens?: number;
output_tokens?: number;
error?: string;
}
export interface BackupInfo {
name: string;
size: number;
modified: string;
}
export interface Settings {
language: string;
port_mode: string;
fixed_port: number;
}
export interface ApplyResult {
provider: Provider;
env: Record<string, string>;
}
export interface Preset {
key: string;
base_url: string;
models: Models;
}
export const PRESETS: Preset[] = [
{
key: "deepseek",
base_url: "https://api.deepseek.com/v1",
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
},
{
key: "moonshot",
base_url: "https://api.moonshot.cn/v1",
models: {
default: "kimi-k2-0905-preview",
sonnet: "kimi-k2-0905-preview",
haiku: "kimi-k2-0905-preview",
opus: "kimi-k2-0905-preview",
},
},
{
key: "zhipu",
base_url: "https://open.bigmodel.cn/api/paas/v4",
models: { default: "glm-4.6", sonnet: "glm-4.6", haiku: "glm-4.6", opus: "glm-4.6" },
},
];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import "./i18n";
import { bootstrap } from "./bootstrap";
bootstrap().then(({ port, language }) => {
(window as unknown as { __CROSSRELAY_PORT__?: number }).__CROSSRELAY_PORT__ = port ?? undefined;
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App initialLanguage={language} />
</React.StrictMode>,
);
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/test/utils";
const getStatus = vi.fn();
const listLogs = vi.fn();
const restore = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
listProviders: vi.fn().mockResolvedValue({ items: [] }),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
applyProvider: vi.fn(),
restore: () => restore(),
getStatus: () => getStatus(),
getSettings: vi.fn().mockResolvedValue({ language: "zh-CN", port_mode: "auto", fixed_port: 0 }),
updateSettings: vi.fn(),
listLogs: () => listLogs(),
listBackups: vi.fn().mockResolvedValue({ items: [] }),
},
}));
import Dashboard from "./Dashboard";
beforeEach(() => {
vi.clearAllMocks();
getStatus.mockResolvedValue({
running: true,
version: "0.1.0",
port: 52917,
uptime_sec: 95,
active_claude_provider: { id: "p_1", name: "DeepSeek", base_url: "https://api.deepseek.com/v1", models: {} },
stats: { requests_total: 7, requests_failed: 1, input_tokens: 100, output_tokens: 200 },
language: "zh-CN",
port_mode: "auto",
fixed_port: 0,
});
listLogs.mockResolvedValue({
items: [
{
time: "2026-10-18T08:00:00Z",
method: "POST",
path: "/v1/messages",
model: "deepseek-chat",
status: 200,
duration_ms: 820,
input_tokens: 40,
output_tokens: 12,
},
],
});
});
describe("Dashboard", () => {
it("renders gateway status, active provider and stats", async () => {
renderWithProviders(<Dashboard />);
expect(await screen.findByText("运行中")).toBeInTheDocument();
expect(screen.getByText(/52917/)).toBeInTheDocument();
expect(screen.getByText("DeepSeek")).toBeInTheDocument();
expect(screen.getByText("7")).toBeInTheDocument(); // requests total
expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); // log model
// 200 shows up both as output tokens and as the log row status.
expect(screen.getAllByText("200").length).toBeGreaterThanOrEqual(2);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { useTranslation } from "react-i18next";
import { RotateCcw, Zap } from "lucide-react";
import { useRestore, useStatus, useLogs } from "@/lib/queries";
import { useToast } from "@/components/toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function formatTime(iso: string): string {
const date = new Date(iso);
return date.toLocaleTimeString();
}
export default function Dashboard() {
const { t } = useTranslation();
const status = useStatus();
const logs = useLogs();
const restore = useRestore();
const { show } = useToast();
if (status.isPending) {
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
}
if (status.isError || !status.data) {
return (
<div className="flex flex-col items-start gap-3">
<span className="text-sm text-red-600">{t("common.error")}</span>
<Button variant="outline" size="sm" onClick={() => status.refetch()}>
{t("common.retry")}
</Button>
</div>
);
}
const s = status.data;
const active = s.active_claude_provider;
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("dashboard.gateway")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge tone={s.running ? "success" : "danger"}>
{s.running ? t("dashboard.running") : t("dashboard.stopped")}
</Badge>
<span className="text-xs text-zinc-500">
{t("dashboard.port")} {s.port}
</span>
</div>
<div className="text-xs text-zinc-500">
{t("dashboard.uptime")} {formatUptime(s.uptime_sec)} · {t("dashboard.version")}{" "}
{s.version}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("dashboard.activeProvider")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{active ? (
<>
<div className="text-sm font-medium">{active.name}</div>
<div className="truncate text-xs text-zinc-500" title={active.base_url}>
{active.base_url}
</div>
</>
) : (
<div className="text-sm text-zinc-500">{t("dashboard.noActiveProvider")}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("dashboard.statsTitle")}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-2 text-xs">
<div>
<dt className="text-zinc-500">{t("dashboard.requests")}</dt>
<dd className="text-base font-semibold">{s.stats.requests_total}</dd>
</div>
<div>
<dt className="text-zinc-500">{t("dashboard.failed")}</dt>
<dd className="text-base font-semibold">{s.stats.requests_failed}</dd>
</div>
<div>
<dt className="text-zinc-500">{t("dashboard.tokensIn")}</dt>
<dd className="text-base font-semibold">{s.stats.input_tokens}</dd>
</div>
<div>
<dt className="text-zinc-500">{t("dashboard.tokensOut")}</dt>
<dd className="text-base font-semibold">{s.stats.output_tokens}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t("dashboard.recentRequests")}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(undefined, {
onSuccess: () => show(t("dashboard.restoreDone")),
onError: (err) => show(err.message, "error"),
})
}
>
{restore.isPending ? (
<RotateCcw size={14} className="animate-spin" aria-hidden />
) : (
<Zap size={14} aria-hidden />
)}
{restore.isPending ? t("common.restoring") : t("common.restore")}
</Button>
</CardHeader>
<CardContent>
{logs.data && logs.data.items.length > 0 ? (
<Table>
<THead>
<TR>
<TH>{t("dashboard.tableTime")}</TH>
<TH>{t("dashboard.tableModel")}</TH>
<TH>{t("dashboard.tableStatus")}</TH>
<TH>{t("dashboard.tableDuration")}</TH>
<TH>{t("dashboard.tableTokens")}</TH>
</TR>
</THead>
<TBody>
{logs.data.items.slice(0, 12).map((entry, index) => (
<TR key={`${entry.time}-${index}`}>
<TD className="text-xs text-zinc-500">{formatTime(entry.time)}</TD>
<TD className="font-mono text-xs">{entry.model ?? entry.path}</TD>
<TD>
<Badge tone={entry.status < 400 ? "success" : "danger"}>{entry.status}</Badge>
</TD>
<TD className="text-xs">{entry.duration_ms} ms</TD>
<TD className="text-xs">
{entry.input_tokens ?? 0} / {entry.output_tokens ?? 0}
</TD>
</TR>
))}
</TBody>
</Table>
) : (
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
)}
</CardContent>
</Card>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "@/test/utils";
import userEvent from "@testing-library/user-event";
const listProviders = vi.fn();
const createProvider = vi.fn();
const deleteProvider = vi.fn();
const applyProvider = vi.fn();
const restore = vi.fn();
const getStatus = vi.fn();
const getSettings = vi.fn();
const listLogs = vi.fn();
const listBackups = vi.fn();
const updateProvider = vi.fn();
const updateSettings = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
listProviders: () => listProviders(),
createProvider: (input: unknown) => createProvider(input),
updateProvider: (id: string, input: unknown) => updateProvider(id, input),
deleteProvider: (id: string) => deleteProvider(id),
applyProvider: (id: string) => applyProvider(id),
restore: () => restore(),
getStatus: () => getStatus(),
getSettings: () => getSettings(),
updateSettings: (patch: unknown) => updateSettings(patch),
listLogs: () => listLogs(),
listBackups: () => listBackups(),
},
}));
import Providers from "./Providers";
const provider = {
id: "p_1",
name: "DeepSeek",
preset: "deepseek",
base_url: "https://api.deepseek.com/v1",
api_key: "sk-x",
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
enabled_for_claude: false,
created_at: "2026-10-18T00:00:00Z",
};
function renderPage() {
return renderWithProviders(<Providers />);
}
beforeEach(() => {
vi.clearAllMocks();
listProviders.mockResolvedValue({ items: [provider] });
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
deleteProvider.mockResolvedValue({ deleted: "p_1" });
applyProvider.mockResolvedValue({ provider, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:1234" } });
});
describe("Providers page", () => {
it("renders the provider list", async () => {
renderPage();
expect(await screen.findByText("DeepSeek")).toBeInTheDocument();
expect(screen.getByText("https://api.deepseek.com/v1")).toBeInTheDocument();
});
it("validates the create form before submitting", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
expect(await screen.findByText(/请填写名称|Name is required/)).toBeInTheDocument();
expect(await screen.findByText(/请填写 http|http\(s\) base URL is required/)).toBeInTheDocument();
expect(createProvider).not.toHaveBeenCalled();
});
it("creates a provider with the preset defaults", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
await user.type(screen.getByLabelText(/名称|Name/), "Kimi");
await user.type(screen.getByLabelText(/Base URL/), "https://api.moonshot.cn/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-kimi");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
const input = createProvider.mock.calls[0][0];
expect(input).toMatchObject({
name: "Kimi",
base_url: "https://api.moonshot.cn/v1",
api_key: "sk-kimi",
});
});
it("activates a provider and shows the applied env", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1"));
expect(await screen.findByText(/ANTHROPIC_BASE_URL/)).toBeInTheDocument();
});
});
+295
View File
@@ -0,0 +1,295 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Pencil } from "lucide-react";
import { PRESETS } from "@/lib/types";
import type { Models, Provider } from "@/lib/types";
import { useProviders, useProviderMutations } from "@/lib/queries";
import { useToast } from "@/components/toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input, Label } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
interface FormState {
id?: string;
name: string;
preset: string;
base_url: string;
api_key: string;
models: Models;
}
const emptyForm: FormState = {
name: "",
preset: "",
base_url: "",
api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: {} },
};
export default function Providers() {
const { t } = useTranslation();
const { data, isLoading, isError } = useProviders();
const mutations = useProviderMutations();
const { show } = useToast();
const [form, setForm] = useState<FormState | null>(null);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [lastAppliedEnv, setLastAppliedEnv] = useState<Record<string, string> | null>(null);
const providers = data?.items ?? [];
const openCreate = (presetKey = "") => {
const preset = PRESETS.find((p) => p.key === presetKey);
setErrors({});
setForm({
...emptyForm,
preset: presetKey,
name: presetKey ? presetKey : "",
base_url: preset?.base_url ?? "",
models: preset ? { ...preset.models } : { ...emptyForm.models },
});
};
const openEdit = (provider: Provider) => {
setErrors({});
setForm({
id: provider.id,
name: provider.name,
preset: provider.preset ?? "",
base_url: provider.base_url,
api_key: "",
models: {
default: provider.models.default ?? "",
sonnet: provider.models.sonnet ?? "",
haiku: provider.models.haiku ?? "",
opus: provider.models.opus ?? "",
extra: { ...(provider.models.extra ?? {}) },
},
});
};
const validate = (): boolean => {
if (!form) return false;
const next: Partial<Record<keyof FormState, string>> = {};
if (!form.name.trim()) next.name = t("providers.validationName");
if (!/^https?:\/\/.+/.test(form.base_url.trim())) next.base_url = t("providers.validationBaseUrl");
if (!form.id && !form.api_key.trim()) next.api_key = t("providers.validationApiKey");
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = () => {
if (!form || !validate()) return;
const input = {
name: form.name.trim(),
preset: form.preset,
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
models: {
default: form.models.default?.trim(),
sonnet: form.models.sonnet?.trim(),
haiku: form.models.haiku?.trim(),
opus: form.models.opus?.trim(),
extra: Object.fromEntries(
Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()),
),
},
};
const onSuccess = () => {
show(form.id ? t("providers.updated") : t("providers.created"));
setForm(null);
};
const onError = (err: Error) => show(err.message, "error");
if (form.id) {
mutations.update.mutate({ id: form.id, input }, { onSuccess, onError });
} else {
mutations.create.mutate(input, { onSuccess, onError });
}
};
const apply = (provider: Provider) => {
mutations.apply.mutate(provider.id, {
onSuccess: (result) => {
setLastAppliedEnv(result.env);
show(`${t("providers.activated")}: ${provider.name}`);
},
onError: (err) => show(err.message, "error"),
});
};
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
if (isError) return <div className="text-sm text-red-600">{t("common.error")}</div>;
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("providers.title")}</h2>
<Button size="sm" onClick={() => openCreate()}>
<Plus size={14} aria-hidden />
{t("providers.add")}
</Button>
</div>
{form && (
<Card>
<CardHeader>
<CardTitle>{form.id ? t("providers.edit") : t("providers.add")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<Label>{t("providers.name")}</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="DeepSeek"
/>
{errors.name && <span className="text-xs text-red-600">{errors.name}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.preset")}</Label>
<Select
value={form.preset}
onChange={(e) => {
const key = e.target.value;
const preset = PRESETS.find((p) => p.key === key);
setForm({
...form,
preset: key,
base_url: preset?.base_url ?? form.base_url,
models: preset ? { ...preset.models } : form.models,
});
}}
>
<option value="">{t("providers.presetCustom")}</option>
{PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.baseUrl")}</Label>
<Input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
placeholder="https://api.deepseek.com/v1"
/>
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>
{t("providers.apiKey")}
{form.id && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
</Label>
<Input
type="password"
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder="sk-..."
/>
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
</label>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
<div className="grid grid-cols-2 gap-3">
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
<label key={role} className="flex flex-col gap-1">
<Label>{t(`providers.${role}Model`)}</Label>
<Input
value={form.models[role] ?? ""}
onChange={(e) =>
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
}
/>
</label>
))}
</div>
</fieldset>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setForm(null)}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={submit}>
{t("common.save")}
</Button>
</div>
</CardContent>
</Card>
)}
{lastAppliedEnv && (
<Card>
<CardHeader>
<CardTitle>{t("providers.appliedEnv")}</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-1 font-mono text-xs text-zinc-600">
{Object.entries(lastAppliedEnv)
.filter(([key]) => key.startsWith("ANTHROPIC_"))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => (
<li key={key}>
<span className="text-zinc-400">{key}</span> = {value}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{providers.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-zinc-500">
{t("providers.empty")}
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-3">
{providers.map((provider) => (
<Card key={provider.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{provider.name}</span>
{provider.enabled_for_claude && <Badge tone="success">{t("providers.activated")}</Badge>}
{provider.preset && <Badge>{provider.preset}</Badge>}
</div>
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
{t("providers.activate")}
</Button>
<Button variant="ghost" size="icon" aria-label={t("common.edit")} onClick={() => openEdit(provider)}>
<Pencil size={14} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() => {
if (window.confirm(t("providers.deleteConfirm"))) {
mutations.remove.mutate(provider.id, {
onSuccess: () => show(t("providers.deleted")),
onError: (err) => show(err.message, "error"),
});
}
}}
>
<Trash2 size={14} aria-hidden />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useBackups, useRestore, useSettings, useSettingsMutation } from "@/lib/queries";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input, Label } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
const { data: settings } = useSettings();
const { data: backups } = useBackups();
const mutation = useSettingsMutation();
const restore = useRestore();
const { show } = useToast();
const [portMode, setPortMode] = useState("auto");
const [fixedPort, setFixedPort] = useState("8787");
const [language, setLanguage] = useState(i18n.language);
useEffect(() => {
if (settings) {
setPortMode(settings.port_mode);
setFixedPort(String(settings.fixed_port || 8787));
setLanguage(settings.language);
}
}, [settings]);
const save = () => {
mutation.mutate(
{
port_mode: portMode,
fixed_port: portMode === "fixed" ? Number(fixedPort) || 0 : 0,
language,
},
{
onSuccess: () => {
void i18n.changeLanguage(language);
show(t("common.saved"));
},
onError: (err) => show(err.message, "error"),
},
);
};
return (
<div className="flex flex-col gap-5">
<h2 className="text-lg font-semibold">{t("settings.title")}</h2>
<Card>
<CardHeader>
<CardTitle>{t("settings.gateway")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<Label>{t("settings.portMode")}</Label>
<Select value={portMode} onChange={(e) => setPortMode(e.target.value)}>
<option value="auto">{t("settings.portAuto")}</option>
<option value="fixed">{t("settings.portFixed")}</option>
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("settings.fixedPort")}</Label>
<Input
type="number"
min={1}
max={65535}
value={fixedPort}
disabled={portMode !== "fixed"}
onChange={(e) => setFixedPort(e.target.value)}
/>
</label>
</div>
<p className="text-xs text-zinc-500">{t("settings.portRestartHint")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("settings.language")}</CardTitle>
</CardHeader>
<CardContent>
<Select value={language} onChange={(e) => setLanguage(e.target.value)} className="max-w-48">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</Select>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t("settings.backups")}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(undefined, {
onSuccess: (result) => show(`${t("settings.restored")}: ${result.restored_from}`),
onError: (err) => show(err.message, "error"),
})
}
>
{t("settings.restoreBackup")}
</Button>
</CardHeader>
<CardContent>
{backups && backups.items && backups.items.length > 0 ? (
<Table>
<THead>
<TR>
<TH>{t("settings.backups")}</TH>
<TH>Size</TH>
<TH>Modified</TH>
</TR>
</THead>
<TBody>
{backups.items.map((item) => (
<TR key={item.name}>
<TD className="font-mono text-xs">{item.name}</TD>
<TD className="text-xs">{item.size} B</TD>
<TD className="text-xs">{new Date(item.modified).toLocaleString()}</TD>
</TR>
))}
</TBody>
</Table>
) : (
<div className="text-sm text-zinc-500">{t("settings.backupsEmpty")}</div>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={save} disabled={mutation.isPending}>
{t("common.save")}
</Button>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
import "@testing-library/jest-dom/vitest";
import "@testing-library/jest-dom/vitest";
import "../i18n";
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToastProvider } from "@/components/toast";
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ToastProvider>{ui}</ToastProvider>
</QueryClientProvider>,
options,
);
}
export { render };
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals", "@testing-library/jest-dom"],
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
port: 1420,
strictPort: true,
},
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
},
});