CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -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 双端同步、单二进制打包为后续阶段。
|
||||
@@ -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)
|
||||
}
|
||||
+12
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "", " ")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
// fakeUpstream mimics a Chat Completions provider. It records the last
|
||||
// request so tests can assert on the converted model and messages.
|
||||
type fakeUpstream struct {
|
||||
lastModel string
|
||||
lastAuth string
|
||||
lastRequest map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
f.lastAuth = r.Header.Get("Authorization")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &f.lastRequest)
|
||||
f.lastModel, _ = f.lastRequest["model"].(string)
|
||||
stream, _ := f.lastRequest["stream"].(bool)
|
||||
if stream {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hi"}}]}`)
|
||||
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":" there"}}]}`)
|
||||
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":2}}`)
|
||||
writeSSELine(w, "data: [DONE]")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"model": f.lastModel,
|
||||
"choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": "Hi there"}, "finish_reason": "stop"}},
|
||||
"usage": map[string]any{"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11},
|
||||
})
|
||||
}
|
||||
|
||||
func writeSSELine(w http.ResponseWriter, line string) {
|
||||
_, _ = io.WriteString(w, line+"\n\n")
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
type testEnv struct {
|
||||
gateway *httptest.Server
|
||||
upstream *fakeUpstream
|
||||
store *config.Store
|
||||
settings string
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
||||
settingsPath := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(settingsPath, []byte(`{"theme":"dark","env":{"DISABLE_AUTOUPDATER":"1"}}`), 0o600); err != nil {
|
||||
t.Fatalf("write settings: %v", err)
|
||||
}
|
||||
t.Setenv("CROSSRELAY_CLAUDE_SETTINGS", settingsPath)
|
||||
|
||||
upstream := &fakeUpstream{}
|
||||
up := httptest.NewServer(upstream)
|
||||
t.Cleanup(up.Close)
|
||||
|
||||
store, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
p, err := store.AddProvider("test", "", up.URL, "up-key", config.Models{
|
||||
Default: "ds-chat",
|
||||
Sonnet: "ds-chat",
|
||||
Haiku: "ds-haiku",
|
||||
Opus: "ds-opus",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProvider: %v", err)
|
||||
}
|
||||
if _, err := store.ActivateClaude(p.ID); err != nil {
|
||||
t.Fatalf("ActivateClaude: %v", err)
|
||||
}
|
||||
|
||||
gw := httptest.NewServer(New(store, 18787).Handler())
|
||||
t.Cleanup(gw.Close)
|
||||
return &testEnv{gateway: gw, upstream: upstream, store: store, settings: settingsPath}
|
||||
}
|
||||
|
||||
func (e *testEnv) post(t *testing.T, path string, body any, token string) *http.Response {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
reader = strings.NewReader(string(data))
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, e.gateway.URL+path, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post %s: %v", path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func messagesBody(model string, stream bool) map[string]any {
|
||||
return map[string]any{
|
||||
"model": model,
|
||||
"max_tokens": 100,
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
"stream": stream,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesNonStream(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out["type"] != "message" || out["role"] != "assistant" {
|
||||
t.Errorf("envelope = %v", out)
|
||||
}
|
||||
if out["model"] != "claude-sonnet-4-5" {
|
||||
t.Errorf("model = %v, want requested model echoed", out["model"])
|
||||
}
|
||||
content := out["content"].([]any)
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("content = %v", content)
|
||||
}
|
||||
block := content[0].(map[string]any)
|
||||
if block["type"] != "text" || block["text"] != "Hi there" {
|
||||
t.Errorf("text block = %v", block)
|
||||
}
|
||||
if out["stop_reason"] != "end_turn" {
|
||||
t.Errorf("stop reason = %v", out["stop_reason"])
|
||||
}
|
||||
usage := out["usage"].(map[string]any)
|
||||
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
|
||||
t.Errorf("usage = %v", usage)
|
||||
}
|
||||
// The upstream must have seen the mapped model and the provider key.
|
||||
if env.upstream.lastModel != "ds-chat" {
|
||||
t.Errorf("upstream model = %q, want ds-chat", env.upstream.lastModel)
|
||||
}
|
||||
if env.upstream.lastAuth != "Bearer up-key" {
|
||||
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesHaikuRoleMapping(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages", messagesBody("claude-3-5-haiku-20241022", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if env.upstream.lastModel != "ds-haiku" {
|
||||
t.Errorf("upstream model = %q, want ds-haiku", env.upstream.lastModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesRequiresToken(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages", messagesBody("m", false), "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
errObj := out["error"].(map[string]any)
|
||||
if errObj["type"] != "authentication_error" {
|
||||
t.Errorf("error = %v", errObj)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesWithoutActiveProvider(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
if err := env.store.ClearActiveClaude(); err != nil {
|
||||
t.Fatalf("ClearActiveClaude: %v", err)
|
||||
}
|
||||
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesStream(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", true), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
||||
t.Fatalf("content type = %q", ct)
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
var events []string
|
||||
var text strings.Builder
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "event: ") {
|
||||
events = append(events, strings.TrimPrefix(line, "event: "))
|
||||
}
|
||||
if strings.HasPrefix(line, "data: ") && strings.Contains(line, "text_delta") {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
|
||||
delta := payload["delta"].(map[string]any)
|
||||
text.WriteString(delta["text"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop"}
|
||||
if len(events) != len(want) {
|
||||
t.Fatalf("events = %v, want %v", events, want)
|
||||
}
|
||||
for i := range want {
|
||||
if events[i] != want[i] {
|
||||
t.Fatalf("events = %v, want %v", events, want)
|
||||
}
|
||||
}
|
||||
if text.String() != "Hi there" {
|
||||
t.Errorf("streamed text = %q", text.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountTokens(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages/count_tokens", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
if out["input_tokens"].(float64) <= 0 {
|
||||
t.Errorf("input tokens = %v", out["input_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAndRestore(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
providers := env.store.Config().Providers
|
||||
if len(providers) != 1 {
|
||||
t.Fatalf("providers = %d", len(providers))
|
||||
}
|
||||
resp := env.post(t, "/api/providers/"+providers[0].ID+"/apply", nil, "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply status = %d", resp.StatusCode)
|
||||
}
|
||||
data, err := os.ReadFile(env.settings)
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
var settings map[string]any
|
||||
_ = json.Unmarshal(data, &settings)
|
||||
if settings["theme"] != "dark" {
|
||||
t.Errorf("theme lost: %v", settings)
|
||||
}
|
||||
envVars := settings["env"].(map[string]any)
|
||||
if envVars["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:18787" {
|
||||
t.Errorf("base url = %v", envVars["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
if envVars["ANTHROPIC_AUTH_TOKEN"] != env.store.Config().RelayToken {
|
||||
t.Errorf("relay token = %v", envVars["ANTHROPIC_AUTH_TOKEN"])
|
||||
}
|
||||
if envVars["DISABLE_AUTOUPDATER"] != "1" {
|
||||
t.Errorf("unrelated env key lost: %v", envVars)
|
||||
}
|
||||
|
||||
resp = env.post(t, "/api/restore", nil, "")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("restore status = %d", resp.StatusCode)
|
||||
}
|
||||
data, _ = os.ReadFile(env.settings)
|
||||
_ = json.Unmarshal(data, &settings)
|
||||
envVars = settings["env"].(map[string]any)
|
||||
if _, ok := envVars["ANTHROPIC_BASE_URL"]; ok {
|
||||
t.Error("base url survived restore")
|
||||
}
|
||||
if env.store.ActiveClaude() != nil {
|
||||
t.Error("active provider survived restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAndLogs(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
get := func(path string) map[string]any {
|
||||
resp, err := http.Get(env.gateway.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return out
|
||||
}
|
||||
status := get("/api/status")
|
||||
if status["running"] != true || status["port"].(float64) != 18787 {
|
||||
t.Errorf("status = %v", status)
|
||||
}
|
||||
stats := status["stats"].(map[string]any)
|
||||
if stats["requests_total"].(float64) < 1 {
|
||||
t.Errorf("stats = %v", stats)
|
||||
}
|
||||
active := status["active_claude_provider"].(map[string]any)
|
||||
if active["name"] != "test" {
|
||||
t.Errorf("active provider = %v", active)
|
||||
}
|
||||
|
||||
logs := get("/api/logs")
|
||||
items := logs["items"].([]any)
|
||||
var messageLog map[string]any
|
||||
for _, item := range items {
|
||||
entry := item.(map[string]any)
|
||||
if entry["path"] == "/v1/messages" {
|
||||
messageLog = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if messageLog == nil {
|
||||
t.Fatalf("no /v1/messages entry in %v", items)
|
||||
}
|
||||
if messageLog["model"] != "ds-chat" {
|
||||
t.Errorf("log model = %v, want the resolved model", messageLog["model"])
|
||||
}
|
||||
if messageLog["status"].(float64) != 200 {
|
||||
t.Errorf("log status = %v", messageLog["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCRUD(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
post := func(path string, body any) *http.Response {
|
||||
data, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, env.gateway.URL+path, strings.NewReader(string(data)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
resp := post("/api/providers", map[string]any{
|
||||
"name": "kimi",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"api_key": "sk-kimi",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status = %d", resp.StatusCode)
|
||||
}
|
||||
if len(env.store.Config().Providers) != 2 {
|
||||
t.Fatalf("providers = %d, want 2", len(env.store.Config().Providers))
|
||||
}
|
||||
resp = post("/api/providers", map[string]any{"name": "", "base_url": "x", "api_key": "y"})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("invalid create status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
// providerInput is the provider payload accepted by the management API.
|
||||
type providerInput struct {
|
||||
Name string `json:"name"`
|
||||
Preset string `json:"preset"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Models config.Models `json:"models"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.store.Config()
|
||||
var active any
|
||||
if p := s.store.ActiveClaude(); p != nil {
|
||||
active = map[string]any{
|
||||
"id": p.ID,
|
||||
"name": p.Name,
|
||||
"base_url": p.BaseURL,
|
||||
"models": p.Models,
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"running": true,
|
||||
"version": Version,
|
||||
"port": s.port,
|
||||
"uptime_sec": int(time.Since(s.started).Seconds()),
|
||||
"active_claude_provider": active,
|
||||
"stats": s.snapshotStats(),
|
||||
"language": cfg.Language,
|
||||
"port_mode": cfg.Gateway.PortMode,
|
||||
"fixed_port": cfg.Gateway.FixedPort,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.store.Config().Providers})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var in providerInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var in providerInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.store.DeleteProvider(id); err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
// handleApply activates a provider and writes it into Claude Code's
|
||||
// settings.json.
|
||||
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := s.store.ActivateClaude(id)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
mgr, err := s.claudeManager()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings, err := mgr.Apply(claudecfg.ApplyOptions{
|
||||
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", s.port),
|
||||
Token: s.store.Config().RelayToken,
|
||||
Models: p.Models,
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"provider": p,
|
||||
"env": settings.Env,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRestore puts back the most recent backup of settings.json.
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
mgr, err := s.claudeManager()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
name, err := mgr.Restore()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.store.ClearActiveClaude()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"restored_from": name})
|
||||
}
|
||||
|
||||
func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) {
|
||||
mgr, err := s.claudeManager()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items, err := mgr.Backups()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []claudecfg.BackupInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.store.Config()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"language": cfg.Language,
|
||||
"port_mode": cfg.Gateway.PortMode,
|
||||
"fixed_port": cfg.Gateway.FixedPort,
|
||||
})
|
||||
}
|
||||
|
||||
type settingsInput struct {
|
||||
Language string `json:"language"`
|
||||
PortMode string `json:"port_mode"`
|
||||
FixedPort int `json:"fixed_port"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var in settingsInput
|
||||
if err := decodeJSON(r, &in, 1<<20); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if in.Language != "" {
|
||||
if err := s.store.SetLanguage(in.Language); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.PortMode != "" {
|
||||
if err := s.store.SetGateway(config.Gateway{
|
||||
PortMode: in.PortMode,
|
||||
FixedPort: in.FixedPort,
|
||||
}); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg := s.store.Config()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"language": cfg.Language,
|
||||
"port_mode": cfg.Gateway.PortMode,
|
||||
"fixed_port": cfg.Gateway.FixedPort,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.logs.list()})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
// logRing keeps the most recent log entries in memory.
|
||||
type logRing struct {
|
||||
mu sync.Mutex
|
||||
entries []LogEntry
|
||||
cap int
|
||||
}
|
||||
|
||||
func newLogRing(capacity int) *logRing {
|
||||
return &logRing{cap: capacity}
|
||||
}
|
||||
|
||||
func (r *logRing) add(entry LogEntry) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries = append(r.entries, entry)
|
||||
if len(r.entries) > r.cap {
|
||||
r.entries = r.entries[len(r.entries)-r.cap:]
|
||||
}
|
||||
}
|
||||
|
||||
// list returns entries newest first.
|
||||
func (r *logRing) list() []LogEntry {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]LogEntry, len(r.entries))
|
||||
for i, e := range r.entries {
|
||||
out[len(r.entries)-1-i] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// handleMessages serves POST /v1/messages in both streaming and
|
||||
// non-streaming modes.
|
||||
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
var req anthropic.MessagesRequest
|
||||
if err := decodeJSON(r, &req, 64<<20); err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
provider := s.store.ActiveClaude()
|
||||
if provider == nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error",
|
||||
"no active provider: activate one in the CrossRelay app")
|
||||
return
|
||||
}
|
||||
model := provider.ResolveModel(req.Model)
|
||||
|
||||
chatReq, err := anthropic.ConvertRequest(&req, model)
|
||||
if err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rec := recordFrom(r)
|
||||
rec.update(func(e *LogEntry) {
|
||||
e.Model = model
|
||||
e.InputTokens = anthropic.EstimateTokens(&req)
|
||||
})
|
||||
|
||||
client := chatcmpl.NewClient(provider.BaseURL, provider.APIKey)
|
||||
if req.Stream {
|
||||
s.handleStream(w, r, client, chatReq, &req, model, rec)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Create(r.Context(), chatReq)
|
||||
if err != nil {
|
||||
s.failUpstream(w, rec, err)
|
||||
return
|
||||
}
|
||||
out := anthropic.ConvertResponse(resp, req.Model)
|
||||
rec.update(func(e *LogEntry) {
|
||||
e.OutputTokens = out.Usage.OutputTokens
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleStream relays a Chat Completions SSE stream as Anthropic events.
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request, client *chatcmpl.Client,
|
||||
chatReq *chatcmpl.Request, req *anthropic.MessagesRequest, model string, rec *record) {
|
||||
|
||||
resp, err := client.CreateStream(r.Context(), chatReq)
|
||||
if err != nil {
|
||||
s.failUpstream(w, rec, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
conv := anthropic.NewStreamConverter(w, req.Model, anthropic.EstimateTokens(req))
|
||||
if err := conv.WriteStart(); err != nil {
|
||||
return
|
||||
}
|
||||
stream := chatcmpl.NewStream(resp.Body)
|
||||
for {
|
||||
chunk, err := stream.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
conv.WriteError("api_error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := conv.OnChunk(chunk); err != nil {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
conv.WriteError("api_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := conv.Finish(); err != nil {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
}
|
||||
}
|
||||
|
||||
// handleCountTokens answers POST /v1/messages/count_tokens with a
|
||||
// heuristic estimate so Claude Code's context meter keeps working.
|
||||
func (s *Server) handleCountTokens(w http.ResponseWriter, r *http.Request) {
|
||||
var req anthropic.MessagesRequest
|
||||
if err := decodeJSON(r, &req, 64<<20); err != nil {
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, anthropic.CountTokensResponse{
|
||||
InputTokens: anthropic.EstimateTokens(&req),
|
||||
})
|
||||
}
|
||||
|
||||
// failUpstream maps an upstream failure onto an Anthropic error.
|
||||
func (s *Server) failUpstream(w http.ResponseWriter, rec *record, err error) {
|
||||
rec.update(func(e *LogEntry) { e.Error = err.Error() })
|
||||
var ue *chatcmpl.UpstreamError
|
||||
if errors.As(err, &ue) {
|
||||
switch {
|
||||
case ue.StatusCode == http.StatusUnauthorized || ue.StatusCode == http.StatusForbidden:
|
||||
writeAnthropicError(w, ue.StatusCode, "authentication_error", ue.Message)
|
||||
case ue.StatusCode == http.StatusTooManyRequests:
|
||||
writeAnthropicError(w, ue.StatusCode, "rate_limit_error", ue.Message)
|
||||
case ue.StatusCode == http.StatusBadRequest:
|
||||
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", ue.Message)
|
||||
default:
|
||||
writeAnthropicError(w, http.StatusBadGateway, "api_error", ue.Message)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeAnthropicError(w, http.StatusBadGateway, "api_error", fmt.Sprintf("upstream request failed: %v", err))
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package gateway serves the Anthropic Messages API to Claude Code and
|
||||
// the local management API to the CrossRelay UI.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
||||
)
|
||||
|
||||
// Version is reported by /api/status.
|
||||
const Version = "0.1.0"
|
||||
|
||||
// Stats aggregates gateway counters.
|
||||
type Stats struct {
|
||||
RequestsTotal int64 `json:"requests_total"`
|
||||
RequestsFailed int64 `json:"requests_failed"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// LogEntry is one recorded request.
|
||||
type LogEntry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status int `json:"status"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
InputTokens int `json:"input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Server wires the config store to the HTTP handlers.
|
||||
type Server struct {
|
||||
store *config.Store
|
||||
port int
|
||||
started time.Time
|
||||
|
||||
logs *logRing
|
||||
|
||||
statsMu sync.Mutex
|
||||
stats Stats
|
||||
}
|
||||
|
||||
// New builds a server bound to the given port.
|
||||
func New(store *config.Store, port int) *Server {
|
||||
return &Server{
|
||||
store: store,
|
||||
port: port,
|
||||
started: time.Now(),
|
||||
logs: newLogRing(100),
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the port the gateway listens on.
|
||||
func (s *Server) Port() int { return s.port }
|
||||
|
||||
// Handler returns the full HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages))
|
||||
mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens))
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
mux.HandleFunc("GET /api/providers", s.handleListProviders)
|
||||
mux.HandleFunc("POST /api/providers", s.handleCreateProvider)
|
||||
mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider)
|
||||
mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider)
|
||||
mux.HandleFunc("POST /api/providers/{id}/apply", s.handleApply)
|
||||
mux.HandleFunc("POST /api/restore", s.handleRestore)
|
||||
mux.HandleFunc("GET /api/backups", s.handleBackups)
|
||||
mux.HandleFunc("GET /api/settings", s.handleGetSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.handleUpdateSettings)
|
||||
mux.HandleFunc("GET /api/logs", s.handleLogs)
|
||||
return s.withLogging(mux)
|
||||
}
|
||||
|
||||
// withAuth protects the Anthropic-compatible endpoints with the local
|
||||
// relay token.
|
||||
func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := s.store.Config().RelayToken
|
||||
if token != "" {
|
||||
got := bearerToken(r)
|
||||
if got == "" {
|
||||
got = r.Header.Get("x-api-key")
|
||||
}
|
||||
if got != token {
|
||||
writeAnthropicError(w, http.StatusUnauthorized, "authentication_error", "invalid relay token")
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// record carries per-request fields from handlers to the logging
|
||||
// middleware.
|
||||
type record struct {
|
||||
mu sync.Mutex
|
||||
entry LogEntry
|
||||
}
|
||||
|
||||
type recordKey struct{}
|
||||
|
||||
func recordFrom(r *http.Request) *record {
|
||||
rec, _ := r.Context().Value(recordKey{}).(*record)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (rec *record) update(fn func(*LogEntry)) {
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
fn(&rec.entry)
|
||||
}
|
||||
|
||||
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &record{entry: LogEntry{
|
||||
Time: start,
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
}}
|
||||
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(ww, r.WithContext(context.WithValue(r.Context(), recordKey{}, rec)))
|
||||
|
||||
rec.mu.Lock()
|
||||
rec.entry.Status = ww.status
|
||||
rec.entry.DurationMS = time.Since(start).Milliseconds()
|
||||
entry := rec.entry
|
||||
rec.mu.Unlock()
|
||||
|
||||
s.logs.add(entry)
|
||||
s.statsMu.Lock()
|
||||
s.stats.RequestsTotal++
|
||||
if entry.Status >= 400 {
|
||||
s.stats.RequestsFailed++
|
||||
}
|
||||
s.stats.InputTokens += int64(entry.InputTokens)
|
||||
s.stats.OutputTokens += int64(entry.OutputTokens)
|
||||
s.statsMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.wroteHeader {
|
||||
w.status = code
|
||||
w.wroteHeader = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush keeps streaming responses unbuffered.
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) claudeManager() (*claudecfg.Manager, error) {
|
||||
path, err := claudecfg.DefaultPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claudecfg.New(path, filepath.Join(config.Dir(), "backups")), nil
|
||||
}
|
||||
|
||||
func (s *Server) snapshotStats() Stats {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
return s.stats
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeAnthropicError(w http.ResponseWriter, status int, errType, message string) {
|
||||
writeJSON(w, status, anthropic.ErrorResponse{
|
||||
Type: "error",
|
||||
Error: anthropic.Detail{Type: errType, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
func writeAPIError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{"error": message})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v any, limit int64) error {
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
|
||||
return dec.Decode(v)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+4486
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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}`);
|
||||
Generated
+4914
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
crossrelay_lib::run()
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import "../i18n";
|
||||
@@ -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 };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user