CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI

- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens,
  management API /api/* (providers CRUD, apply/restore, settings, backups, logs)
- Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming
  state machine keyed by content block index (text, tool_use, parallel calls)
- ~/.claude/settings.json guarded read/write: timestamped backups, managed env
  keys only, one-click restore
- Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout,
  stdin pipe watchdog prevents orphans, kills the child on exit
- React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query
- Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
package anthropic
import (
"encoding/json"
"fmt"
"strings"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// ConvertRequest converts an Anthropic Messages request into a Chat
// Completions request addressed to the given upstream model.
func ConvertRequest(req *MessagesRequest, model string) (*chatcmpl.Request, error) {
out := &chatcmpl.Request{
Model: model,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
Stop: req.StopSequences,
Stream: req.Stream,
}
if txt := SystemText(req.System); txt != "" {
out.Messages = append(out.Messages, chatcmpl.Message{Role: "system", Content: txt})
}
for _, m := range req.Messages {
blocks, err := ParseContent(m.Content)
if err != nil {
return nil, fmt.Errorf("parse message content: %w", err)
}
converted, err := convertMessage(m.Role, blocks)
if err != nil {
return nil, err
}
out.Messages = append(out.Messages, converted...)
}
for _, t := range req.Tools {
out.Tools = append(out.Tools, chatcmpl.Tool{
Type: "function",
Function: chatcmpl.ToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
},
})
}
choice, err := convertToolChoice(req.ToolChoice)
if err != nil {
return nil, err
}
out.ToolChoice = choice
if req.Stream {
out.StreamOptions = &chatcmpl.StreamOptions{IncludeUsage: true}
}
return out, nil
}
func convertMessage(role string, blocks []Block) ([]chatcmpl.Message, error) {
var out []chatcmpl.Message
var parts []chatcmpl.ContentPart
flush := func() {
if len(parts) == 0 {
return
}
if len(parts) == 1 && parts[0].Type == "text" {
out = append(out, chatcmpl.Message{Role: role, Content: parts[0].Text})
} else {
out = append(out, chatcmpl.Message{Role: role, Content: parts})
}
parts = nil
}
for _, b := range blocks {
switch b.Type {
case "text":
parts = append(parts, chatcmpl.ContentPart{Type: "text", Text: b.Text})
case "image":
url, err := imageURL(b.Source)
if err != nil {
return nil, err
}
parts = append(parts, chatcmpl.ContentPart{Type: "image_url", ImageURL: &chatcmpl.ImageURL{URL: url}})
case "tool_use":
flush()
args := string(b.Input)
if strings.TrimSpace(args) == "" {
args = "{}"
}
out = append(out, chatcmpl.Message{
Role: "assistant",
ToolCalls: []chatcmpl.ToolCall{{
ID: b.ID,
Type: "function",
Function: chatcmpl.ToolCallFunction{Name: b.Name, Arguments: args},
}},
})
case "tool_result":
flush()
out = append(out, chatcmpl.Message{
Role: "tool",
ToolCallID: b.ToolUseID,
Content: TextOf(b.Content),
})
}
}
flush()
return out, nil
}
func imageURL(src *ImageSource) (string, error) {
if src == nil {
return "", fmt.Errorf("image block without source")
}
switch {
case src.URL != "":
return src.URL, nil
case src.Type == "base64" && src.Data != "":
mediaType := src.MediaType
if mediaType == "" {
mediaType = "image/png"
}
return "data:" + mediaType + ";base64," + src.Data, nil
default:
return "", fmt.Errorf("unsupported image source type %q", src.Type)
}
}
func convertToolChoice(raw json.RawMessage) (any, error) {
if len(raw) == 0 {
return nil, nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return toolChoiceFromType(s)
}
var obj struct {
Type string `json:"type"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &obj) == nil {
if obj.Type == "tool" && obj.Name != "" {
return map[string]any{
"type": "function",
"function": map[string]any{"name": obj.Name},
}, nil
}
return toolChoiceFromType(obj.Type)
}
return nil, nil
}
func toolChoiceFromType(t string) (any, error) {
switch t {
case "auto", "none":
return t, nil
case "any":
return "required", nil
case "":
return nil, nil
default:
// Unknown tool choice: let the upstream decide.
return nil, nil
}
}
// ConvertResponse converts a Chat Completions response into an Anthropic
// Messages response. requestedModel is echoed back to the client.
func ConvertResponse(resp *chatcmpl.Response, requestedModel string) *MessagesResponse {
out := &MessagesResponse{
ID: "msg_" + resp.ID,
Type: "message",
Role: "assistant",
Model: requestedModel,
Content: []Block{},
}
if len(resp.Choices) > 0 {
c := resp.Choices[0]
out.Content = convertAssistantMessage(c.Message)
out.StopReason = MapStopReason(c.FinishReason)
} else {
out.StopReason = "end_turn"
}
if resp.Usage != nil {
out.Usage = Usage{
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}
}
return out
}
func convertAssistantMessage(m chatcmpl.Message) []Block {
var blocks []Block
if txt := messageText(m.Content); txt != "" {
blocks = append(blocks, Block{Type: "text", Text: txt})
}
for _, tc := range m.ToolCalls {
input := json.RawMessage(tc.Function.Arguments)
if len(input) == 0 {
input = json.RawMessage("{}")
}
blocks = append(blocks, Block{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
return blocks
}
func messageText(content any) string {
switch v := content.(type) {
case string:
return v
case []chatcmpl.ContentPart:
var sb strings.Builder
for _, p := range v {
if p.Type == "text" {
sb.WriteString(p.Text)
}
}
return sb.String()
default:
return ""
}
}
// MapStopReason maps a Chat Completions finish_reason onto an Anthropic
// stop_reason.
func MapStopReason(finish string) string {
switch finish {
case "stop", "":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls", "function_call":
return "tool_use"
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
// EstimateTokens returns a rough input-token estimate for a request. It
// powers /v1/messages/count_tokens so Claude Code's context meter works
// without a real tokenizer.
func EstimateTokens(req *MessagesRequest) int {
total := len(req.Model)/4 + 4
total += len(SystemText(req.System)) / 4
for _, m := range req.Messages {
total += len(m.Role) + 4
blocks, err := ParseContent(m.Content)
if err != nil {
continue
}
for _, b := range blocks {
switch b.Type {
case "text":
total += len(b.Text) / 4
case "tool_use":
total += len(b.Name)/4 + len(b.Input)/4
case "tool_result":
total += len(TextOf(b.Content)) / 4
case "image":
total += 1100
}
}
}
for _, t := range req.Tools {
total += len(t.Name)/4 + len(t.Description)/4 + len(t.InputSchema)/4
}
if total < 1 {
total = 1
}
return total
}
+241
View File
@@ -0,0 +1,241 @@
package anthropic
import (
"encoding/json"
"reflect"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
func ptr[T any](v T) *T { return &v }
func TestConvertRequestTextAndSystem(t *testing.T) {
req := &MessagesRequest{
Model: "claude-sonnet-4-5",
MaxTokens: 1024,
System: json.RawMessage(`"You are helpful"`),
Messages: []Message{
{Role: "user", Content: json.RawMessage(`"hi"`)},
},
Temperature: ptr(0.5),
TopP: ptr(0.9),
}
out, err := ConvertRequest(req, "ds-chat")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if out.Model != "ds-chat" {
t.Errorf("model = %q", out.Model)
}
if out.MaxTokens != 1024 {
t.Errorf("max tokens = %d", out.MaxTokens)
}
if len(out.Messages) != 2 {
t.Fatalf("messages = %d, want 2 (system + user)", len(out.Messages))
}
if out.Messages[0].Role != "system" || out.Messages[0].Content != "You are helpful" {
t.Errorf("system message = %+v", out.Messages[0])
}
if out.Messages[1].Role != "user" || out.Messages[1].Content != "hi" {
t.Errorf("user message = %+v", out.Messages[1])
}
if out.StreamOptions != nil {
t.Error("stream_options set for non-stream request")
}
}
func TestConvertRequestSystemBlocks(t *testing.T) {
req := &MessagesRequest{
Model: "m",
System: json.RawMessage(`[{"type":"text","text":"one"},{"type":"text","text":"two","cache_control":{"type":"ephemeral"}}]`),
}
out, err := ConvertRequest(req, "m")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if len(out.Messages) != 1 || out.Messages[0].Content != "one\n\ntwo" {
t.Fatalf("system flattening = %+v", out.Messages)
}
}
func TestConvertRequestToolsAndToolResult(t *testing.T) {
req := &MessagesRequest{
Model: "claude-sonnet-4-5",
Messages: []Message{
{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"look"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAA="}}]`)},
{Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"read_file","input":{"path":"a.txt"}}]`)},
{Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"file body"}]`)},
},
Tools: []Tool{{Name: "read_file", Description: "reads", InputSchema: json.RawMessage(`{"type":"object"}`)}},
}
out, err := ConvertRequest(req, "ds-chat")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
wantRoles := []string{"user", "assistant", "assistant", "tool"}
if len(out.Messages) != len(wantRoles) {
t.Fatalf("messages = %d (%+v), want %d", len(out.Messages), out.Messages, len(wantRoles))
}
for i, want := range wantRoles {
if out.Messages[i].Role != want {
t.Errorf("message %d role = %q, want %q", i, out.Messages[i].Role, want)
}
}
// user message became multi-part with an image data URL.
parts, ok := out.Messages[0].Content.([]chatcmpl.ContentPart)
if !ok || len(parts) != 2 || parts[1].Type != "image_url" {
t.Fatalf("user content = %+v", out.Messages[0].Content)
}
if parts[1].ImageURL.URL != "data:image/png;base64,AAA=" {
t.Errorf("image url = %q", parts[1].ImageURL.URL)
}
// assistant tool_use became a tool_calls message.
if len(out.Messages[2].ToolCalls) != 1 {
t.Fatalf("tool calls = %+v", out.Messages[2].ToolCalls)
}
tc := out.Messages[2].ToolCalls[0]
if tc.ID != "toolu_1" || tc.Function.Name != "read_file" || tc.Function.Arguments != `{"path":"a.txt"}` {
t.Errorf("tool call = %+v", tc)
}
// tool_result became a tool message.
if out.Messages[3].ToolCallID != "toolu_1" || out.Messages[3].Content != "file body" {
t.Errorf("tool message = %+v", out.Messages[3])
}
// tools converted to function tools.
if len(out.Tools) != 1 || out.Tools[0].Type != "function" || out.Tools[0].Function.Name != "read_file" {
t.Errorf("tools = %+v", out.Tools)
}
if string(out.Tools[0].Function.Parameters) != `{"type":"object"}` {
t.Errorf("tool parameters = %s", out.Tools[0].Function.Parameters)
}
}
func TestConvertRequestStreamSetsUsageOption(t *testing.T) {
out, err := ConvertRequest(&MessagesRequest{Model: "m", Stream: true}, "m")
if err != nil {
t.Fatalf("ConvertRequest: %v", err)
}
if out.StreamOptions == nil || !out.StreamOptions.IncludeUsage {
t.Errorf("stream options = %+v, want include_usage", out.StreamOptions)
}
}
func TestConvertToolChoice(t *testing.T) {
cases := []struct {
raw string
want any
}{
{`"auto"`, "auto"},
{`"any"`, "required"},
{`"none"`, "none"},
{`{"type":"tool","name":"read_file"}`, map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}},
{`{"type":"auto"}`, "auto"},
{`{"type":"weird"}`, nil},
{`"weird"`, nil},
}
for _, tc := range cases {
got, err := convertToolChoice(json.RawMessage(tc.raw))
if err != nil {
t.Fatalf("convertToolChoice(%s): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("convertToolChoice(%s) = %#v, want %#v", tc.raw, got, tc.want)
}
}
}
func TestConvertResponse(t *testing.T) {
resp := &chatcmpl.Response{
ID: "chatcmpl-1",
Model: "ds-chat",
Choices: []chatcmpl.Choice{{
FinishReason: "tool_calls",
Message: chatcmpl.Message{
Role: "assistant",
Content: "thinking",
ToolCalls: []chatcmpl.ToolCall{{
ID: "call_1",
Type: "function",
Function: chatcmpl.ToolCallFunction{
Name: "read_file",
Arguments: `{"path":"a.txt"}`,
},
}},
},
}},
Usage: &chatcmpl.Usage{PromptTokens: 11, CompletionTokens: 7},
}
out := ConvertResponse(resp, "claude-sonnet-4-5")
if out.Type != "message" || out.Role != "assistant" {
t.Errorf("envelope = %+v", out)
}
if out.Model != "claude-sonnet-4-5" {
t.Errorf("model = %q, want the requested model echoed", out.Model)
}
if len(out.Content) != 2 {
t.Fatalf("content = %+v", out.Content)
}
if out.Content[0].Type != "text" || out.Content[0].Text != "thinking" {
t.Errorf("text block = %+v", out.Content[0])
}
if out.Content[1].Type != "tool_use" || out.Content[1].ID != "call_1" || out.Content[1].Name != "read_file" {
t.Errorf("tool block = %+v", out.Content[1])
}
if string(out.Content[1].Input) != `{"path":"a.txt"}` {
t.Errorf("tool input = %s", out.Content[1].Input)
}
if out.StopReason != "tool_use" {
t.Errorf("stop reason = %q", out.StopReason)
}
if out.Usage.InputTokens != 11 || out.Usage.OutputTokens != 7 {
t.Errorf("usage = %+v", out.Usage)
}
}
func TestMapStopReason(t *testing.T) {
cases := map[string]string{
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"function_call": "tool_use",
"content_filter": "refusal",
"": "end_turn",
"mystery": "end_turn",
}
for finish, want := range cases {
if got := MapStopReason(finish); got != want {
t.Errorf("MapStopReason(%q) = %q, want %q", finish, got, want)
}
}
}
func TestEstimateTokens(t *testing.T) {
small := &MessagesRequest{Model: "m", Messages: []Message{{Role: "user", Content: json.RawMessage(`"hi"`)}}}
long := strings.Repeat("token ", 800)
big := &MessagesRequest{Model: "m", Messages: []Message{{Role: "user", Content: json.RawMessage(`"` + long + `"`)}}}
if EstimateTokens(small) <= 0 {
t.Error("estimate should be positive")
}
if EstimateTokens(big) <= EstimateTokens(small)*10 {
t.Errorf("estimate does not scale: small=%d big=%d", EstimateTokens(small), EstimateTokens(big))
}
}
func TestParseContentStringAndBlocks(t *testing.T) {
blocks, err := ParseContent(json.RawMessage(`"plain"`))
if err != nil || len(blocks) != 1 || blocks[0].Type != "text" || blocks[0].Text != "plain" {
t.Fatalf("string content = %+v (%v)", blocks, err)
}
blocks, err = ParseContent(json.RawMessage(`null`))
if err != nil || blocks != nil {
t.Fatalf("null content = %+v (%v)", blocks, err)
}
if TextOf(json.RawMessage(`"x"`)) != "x" {
t.Error("TextOf(string) failed")
}
if TextOf(json.RawMessage(`[{"type":"text","text":"a"},{"type":"text","text":"b"}]`)) != "ab" {
t.Error("TextOf(blocks) failed")
}
}
+32
View File
@@ -0,0 +1,32 @@
package anthropic
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
// flusherOf extracts the http.Flusher when the writer supports it.
func flusherOf(w http.ResponseWriter) http.Flusher {
if f, ok := w.(http.Flusher); ok {
return f
}
return nil
}
// writeEvent emits one Server-Sent Event in Anthropic's format:
// "event: <name>\ndata: <json>\n\n".
func writeEvent(w io.Writer, f http.Flusher, name string, data any) error {
payload, err := json.Marshal(data)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, payload); err != nil {
return err
}
if f != nil {
f.Flush()
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package anthropic
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// StreamConverter synthesizes Anthropic SSE events from Chat Completions
// stream chunks.
//
// Content blocks are tracked by their Anthropic index: one text block
// (opened on the first text delta) and one tool_use block per upstream
// tool-call index. Deltas always carry the block index, so interleaved
// tool-call arguments from the upstream stream are attributed to the
// right block. All open blocks are closed in Finish, followed by
// message_delta and message_stop.
type StreamConverter struct {
w http.ResponseWriter
flusher http.Flusher
model string
msgID string
started bool
nextIndex int
textOpen bool
textIndex int
tools map[int]*toolBlock
toolOrder []int
inputEstimate int
outputChars int
usage *chatcmpl.Usage
finishReason string
}
type toolBlock struct {
index int
id string
name string
started bool
pending string
}
// NewStreamConverter builds a converter writing to w. model is echoed in
// message_start; inputTokens seeds the usage shown to the client.
func NewStreamConverter(w http.ResponseWriter, model string, inputTokens int) *StreamConverter {
return &StreamConverter{
w: w,
flusher: flusherOf(w),
model: model,
msgID: "msg_" + randHex(16),
tools: map[int]*toolBlock{},
inputEstimate: inputTokens,
}
}
// WriteStart sends the SSE headers and the message_start event.
func (s *StreamConverter) WriteStart() error {
h := s.w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
s.w.WriteHeader(http.StatusOK)
s.started = true
return writeEvent(s.w, s.flusher, "message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": s.msgID,
"type": "message",
"role": "assistant",
"content": []any{},
"model": s.model,
"stop_reason": nil,
"stop_sequence": nil,
"usage": map[string]any{"input_tokens": s.inputEstimate, "output_tokens": 0},
},
})
}
// OnChunk folds one upstream chunk into the Anthropic event stream.
func (s *StreamConverter) OnChunk(chunk *chatcmpl.StreamChunk) error {
if chunk == nil {
return nil
}
if chunk.Usage != nil {
u := *chunk.Usage
s.usage = &u
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
if err := s.writeText(*choice.Delta.Content); err != nil {
return err
}
}
for _, tc := range choice.Delta.ToolCalls {
if err := s.toolDelta(tc); err != nil {
return err
}
}
if choice.FinishReason != nil && *choice.FinishReason != "" {
s.finishReason = *choice.FinishReason
}
}
return nil
}
func (s *StreamConverter) writeText(text string) error {
if !s.textOpen {
s.textIndex = s.nextIndex
s.nextIndex++
s.textOpen = true
if err := writeEvent(s.w, s.flusher, "content_block_start", map[string]any{
"type": "content_block_start",
"index": s.textIndex,
"content_block": map[string]any{"type": "text", "text": ""},
}); err != nil {
return err
}
}
s.outputChars += len(text)
return writeEvent(s.w, s.flusher, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": s.textIndex,
"delta": map[string]any{"type": "text_delta", "text": text},
})
}
func (s *StreamConverter) toolDelta(tc chatcmpl.ToolCall) error {
st := s.tools[tc.Index]
if st == nil {
st = &toolBlock{}
s.tools[tc.Index] = st
s.toolOrder = append(s.toolOrder, tc.Index)
}
if tc.ID != "" && st.id == "" {
st.id = tc.ID
}
if tc.Function.Name != "" && st.name == "" {
st.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
st.pending += tc.Function.Arguments
}
// Open the block once we know a name or have arguments to flush, so
// the client never sees an unnamed tool_use block.
if !st.started && (st.name != "" || st.pending != "") {
st.index = s.nextIndex
s.nextIndex++
st.started = true
if st.id == "" {
st.id = fmt.Sprintf("toolu_%s_%d", s.msgID[len("msg_"):], tc.Index)
}
if err := writeEvent(s.w, s.flusher, "content_block_start", map[string]any{
"type": "content_block_start",
"index": st.index,
"content_block": map[string]any{
"type": "tool_use",
"id": st.id,
"name": st.name,
"input": map[string]any{},
},
}); err != nil {
return err
}
s.outputChars += len(st.name)
}
if st.started && st.pending != "" {
s.outputChars += len(st.pending)
pending := st.pending
st.pending = ""
return writeEvent(s.w, s.flusher, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": st.index,
"delta": map[string]any{"type": "input_json_delta", "partial_json": pending},
})
}
return nil
}
// Finish closes every open block and ends the message.
func (s *StreamConverter) Finish() error {
if s.textOpen {
if err := writeEvent(s.w, s.flusher, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": s.textIndex,
}); err != nil {
return err
}
s.textOpen = false
}
for _, idx := range s.toolOrder {
st := s.tools[idx]
if st == nil || !st.started {
continue
}
if err := writeEvent(s.w, s.flusher, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": st.index,
}); err != nil {
return err
}
}
outputTokens := 0
if s.usage != nil {
outputTokens = s.usage.CompletionTokens
} else {
outputTokens = (s.outputChars + 3) / 4
}
usage := map[string]any{"output_tokens": outputTokens}
if s.usage != nil {
usage["input_tokens"] = s.usage.PromptTokens
}
if err := writeEvent(s.w, s.flusher, "message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": MapStopReason(s.finishReason), "stop_sequence": nil},
"usage": usage,
}); err != nil {
return err
}
return writeEvent(s.w, s.flusher, "message_stop", map[string]any{"type": "message_stop"})
}
// WriteError terminates the stream with an Anthropic error event.
func (s *StreamConverter) WriteError(errType, message string) {
_ = writeEvent(s.w, s.flusher, "error", map[string]any{
"type": "error",
"error": map[string]any{"type": errType, "message": message},
})
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", n)
}
return hex.EncodeToString(b)
}
+277
View File
@@ -0,0 +1,277 @@
package anthropic
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// captureWriter records everything the converter writes.
type captureWriter struct {
header http.Header
body bytes.Buffer
status int
flushes int
}
func newCaptureWriter() *captureWriter {
return &captureWriter{header: http.Header{}}
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) Write(b []byte) (int, error) { return c.body.Write(b) }
func (c *captureWriter) WriteHeader(status int) { c.status = status }
func (c *captureWriter) Flush() { c.flushes++ }
type sseEvent struct {
name string
data map[string]any
}
func parseSSE(t *testing.T, data string) []sseEvent {
t.Helper()
var events []sseEvent
for _, block := range strings.Split(strings.TrimSpace(data), "\n\n") {
if block == "" {
continue
}
var name string
var payload string
for _, line := range strings.Split(block, "\n") {
switch {
case strings.HasPrefix(line, "event: "):
name = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
payload = strings.TrimPrefix(line, "data: ")
}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
t.Fatalf("event %s payload is not JSON: %v (%s)", name, err, payload)
}
events = append(events, sseEvent{name: name, data: parsed})
}
return events
}
func eventNames(events []sseEvent) []string {
names := make([]string, len(events))
for i, e := range events {
names[i] = e.name
}
return names
}
func textChunk(text string) *chatcmpl.StreamChunk {
return &chatcmpl.StreamChunk{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{Content: &text}}}}
}
func finishChunk(reason string, usage *chatcmpl.Usage) *chatcmpl.StreamChunk {
return &chatcmpl.StreamChunk{
Choices: []chatcmpl.ChunkChoice{{FinishReason: &reason}},
Usage: usage,
}
}
func runConverter(t *testing.T, chunks []*chatcmpl.StreamChunk) []sseEvent {
t.Helper()
w := newCaptureWriter()
conv := NewStreamConverter(w, "claude-sonnet-4-5", 42)
if err := conv.WriteStart(); err != nil {
t.Fatalf("WriteStart: %v", err)
}
for _, c := range chunks {
if err := conv.OnChunk(c); err != nil {
t.Fatalf("OnChunk: %v", err)
}
}
if err := conv.Finish(); err != nil {
t.Fatalf("Finish: %v", err)
}
if w.status != http.StatusOK {
t.Errorf("status = %d", w.status)
}
if ct := w.header.Get("Content-Type"); ct != "text/event-stream; charset=utf-8" {
t.Errorf("content type = %q", ct)
}
return parseSSE(t, w.body.String())
}
func TestStreamTextOnly(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
textChunk("Hello"),
textChunk(", world"),
finishChunk("stop", &chatcmpl.Usage{PromptTokens: 10, CompletionTokens: 3}),
})
wantNames := []string{
"message_start",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
}
if got := eventNames(events); !equalStrings(got, wantNames) {
t.Fatalf("events = %v, want %v", got, wantNames)
}
start := events[0].data["message"].(map[string]any)
if start["model"] != "claude-sonnet-4-5" {
t.Errorf("message model = %v", start["model"])
}
usage := start["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 42 {
t.Errorf("input tokens = %v, want 42", usage["input_tokens"])
}
if events[2].data["delta"].(map[string]any)["text"] != "Hello" {
t.Errorf("first delta = %v", events[2].data["delta"])
}
delta := events[5].data["delta"].(map[string]any)
if delta["stop_reason"] != "end_turn" {
t.Errorf("stop reason = %v", delta["stop_reason"])
}
if events[5].data["usage"].(map[string]any)["output_tokens"].(float64) != 3 {
t.Errorf("output tokens = %v", events[5].data["usage"])
}
}
func TestStreamToolCall(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0,
ID: "call_1",
Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "read_file"},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0,
Function: chatcmpl.ToolCallFunction{Arguments: `{"path":`},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0,
Function: chatcmpl.ToolCallFunction{Arguments: `"a.txt"}`},
}}}}}},
finishChunk("tool_calls", nil),
})
wantNames := []string{
"message_start",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
}
if got := eventNames(events); !equalStrings(got, wantNames) {
t.Fatalf("events = %v, want %v", got, wantNames)
}
block := events[1].data["content_block"].(map[string]any)
if block["type"] != "tool_use" || block["id"] != "call_1" || block["name"] != "read_file" {
t.Fatalf("tool block = %v", block)
}
if events[2].data["delta"].(map[string]any)["partial_json"] != `{"path":` {
t.Errorf("first args delta = %v", events[2].data["delta"])
}
if events[3].data["delta"].(map[string]any)["partial_json"] != `"a.txt"}` {
t.Errorf("second args delta = %v", events[3].data["delta"])
}
// Every event for the same block carries index 0.
for _, e := range events[1:5] {
if e.data["index"].(float64) != 0 {
t.Errorf("%s index = %v, want 0", e.name, e.data["index"])
}
}
if events[5].data["delta"].(map[string]any)["stop_reason"] != "tool_use" {
t.Errorf("stop reason = %v", events[5].data["delta"])
}
// No usage chunk from upstream: output tokens are estimated, not zero.
if events[5].data["usage"].(map[string]any)["output_tokens"].(float64) <= 0 {
t.Error("estimated output tokens should be positive")
}
}
func TestStreamParallelToolCalls(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 0, ID: "call_a", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "tool_a"},
}}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
Index: 1, ID: "call_b", Type: "function",
Function: chatcmpl.ToolCallFunction{Name: "tool_b", Arguments: `{}`},
}}}}}},
finishChunk("tool_calls", nil),
})
// Two separate blocks: index 0 for tool_a, index 1 for tool_b.
starts := 0
names := map[float64]string{}
for _, e := range events {
if e.name != "content_block_start" {
continue
}
starts++
idx := e.data["index"].(float64)
names[idx] = e.data["content_block"].(map[string]any)["name"].(string)
}
if starts != 2 {
t.Fatalf("content_block_start count = %d, want 2", starts)
}
if names[0] != "tool_a" || names[1] != "tool_b" {
t.Errorf("block names = %v", names)
}
}
func TestStreamInterleavedToolDeltas(t *testing.T) {
// Upstream sends argument deltas for two indices in an interleaved
// order; each delta must land on its own block index.
events := runConverter(t, []*chatcmpl.StreamChunk{
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 0, ID: "a", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "tool_a"}},
}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 1, ID: "b", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "tool_b"}},
}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"x":1}`}},
}}}}},
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
{Index: 1, Function: chatcmpl.ToolCallFunction{Arguments: `{"y":2}`}},
}}}}},
finishChunk("tool_calls", nil),
})
args := map[float64]string{}
for _, e := range events {
if e.name != "content_block_delta" {
continue
}
idx := e.data["index"].(float64)
args[idx] += e.data["delta"].(map[string]any)["partial_json"].(string)
}
if args[0] != `{"x":1}` || args[1] != `{"y":2}` {
t.Errorf("interleaved args = %v", args)
}
}
func TestStreamEmptyUpstream(t *testing.T) {
events := runConverter(t, []*chatcmpl.StreamChunk{})
want := []string{"message_start", "message_delta", "message_stop"}
if got := eventNames(events); !equalStrings(got, want) {
t.Fatalf("events = %v, want %v", got, want)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+166
View File
@@ -0,0 +1,166 @@
// Package anthropic describes the Anthropic Messages API that Claude
// Code speaks and converts it to and from Chat Completions.
package anthropic
import (
"bytes"
"encoding/json"
)
// MessagesRequest is the Anthropic Messages API request body.
type MessagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Messages []Message `json:"messages"`
System json.RawMessage `json:"system,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// Message is one conversation turn. Content is either a plain string or
// an array of blocks.
type Message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
// Tool is an Anthropic tool definition.
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"`
}
// Block is a single content block inside a message. Only the fields
// belonging to Type are populated.
type Block struct {
Type string `json:"type"`
// text
Text string `json:"text,omitempty"`
// image
Source *ImageSource `json:"source,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
// cache_control is accepted but never forwarded upstream.
CacheControl json.RawMessage `json:"cache_control,omitempty"`
}
// ImageSource is an image referenced by a user message.
type ImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type,omitempty"`
Data string `json:"data,omitempty"`
URL string `json:"url,omitempty"`
}
// MessagesResponse is the Anthropic Messages API response body.
type MessagesResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []Block `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence"`
Usage Usage `json:"usage"`
}
// Usage reports token counts.
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// ErrorResponse is the Anthropic error envelope.
type ErrorResponse struct {
Type string `json:"type"`
Error Detail `json:"error"`
}
// Detail is the error payload.
type Detail struct {
Type string `json:"type"`
Message string `json:"message"`
}
// CountTokensResponse is the response of /v1/messages/count_tokens.
type CountTokensResponse struct {
InputTokens int `json:"input_tokens"`
}
// ParseContent decodes a message content field into blocks. A plain
// string becomes a single text block.
func ParseContent(raw json.RawMessage) ([]Block, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || string(trimmed) == "null" {
return nil, nil
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return nil, err
}
if s == "" {
return nil, nil
}
return []Block{{Type: "text", Text: s}}, nil
}
var blocks []Block
if err := json.Unmarshal(trimmed, &blocks); err != nil {
return nil, err
}
return blocks, nil
}
// SystemText flattens a system field (string or block array) into plain
// text.
func SystemText(raw json.RawMessage) string {
blocks, err := ParseContent(raw)
if err != nil {
return ""
}
var parts []string
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
}
}
return joinBlocks(parts)
}
// TextOf extracts plain text from a tool_result content field.
func TextOf(raw json.RawMessage) string {
blocks, err := ParseContent(raw)
if err != nil {
return ""
}
var sb bytes.Buffer
for _, b := range blocks {
if b.Type == "text" {
sb.WriteString(b.Text)
}
}
return sb.String()
}
func joinBlocks(parts []string) string {
if len(parts) == 0 {
return ""
}
out := parts[0]
for _, p := range parts[1:] {
out += "\n\n" + p
}
return out
}
+106
View File
@@ -0,0 +1,106 @@
package chatcmpl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// UpstreamError is returned when the upstream provider answers with a
// non-2xx status or an error payload.
type UpstreamError struct {
StatusCode int
Message string
}
func (e *UpstreamError) Error() string {
return fmt.Sprintf("upstream %d: %s", e.StatusCode, e.Message)
}
// Client talks to one Chat Completions endpoint.
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
}
// NewClient builds a client for the given base URL and API key.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
APIKey: apiKey,
HTTP: &http.Client{Timeout: 30 * time.Minute},
}
}
func (c *Client) endpoint() string {
return c.BaseURL + "/chat/completions"
}
func (c *Client) do(ctx context.Context, req *Request) (*http.Response, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
if c.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
}
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
msg := readErrorBody(resp)
return nil, &UpstreamError{StatusCode: resp.StatusCode, Message: msg}
}
return resp, nil
}
// Create performs a non-streaming completion.
func (c *Client) Create(ctx context.Context, req *Request) (*Response, error) {
resp, err := c.do(ctx, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out Response
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&out); err != nil {
return nil, fmt.Errorf("decode upstream response: %w", err)
}
if out.Error != nil && out.Error.Message != "" {
return nil, &UpstreamError{StatusCode: resp.StatusCode, Message: out.Error.Message}
}
return &out, nil
}
// CreateStream performs a streaming completion. The caller reads SSE
// events from the response body and must close it.
func (c *Client) CreateStream(ctx context.Context, req *Request) (*http.Response, error) {
return c.do(ctx, req)
}
func readErrorBody(resp *http.Response) string {
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil || len(data) == 0 {
return resp.Status
}
var payload struct {
Error *Error `json:"error"`
}
if json.Unmarshal(data, &payload) == nil && payload.Error != nil && payload.Error.Message != "" {
return payload.Error.Message
}
return strings.TrimSpace(string(data))
}
+117
View File
@@ -0,0 +1,117 @@
package chatcmpl
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateNonStream(t *testing.T) {
var gotAuth, gotBody string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
data, _ := io.ReadAll(r.Body)
gotBody = string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"cmpl-1","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk-up")
resp, err := client.Create(context.Background(), &Request{Model: "ds-chat", Messages: []Message{{Role: "user", Content: "hi"}}})
if err != nil {
t.Fatalf("Create: %v", err)
}
if gotAuth != "Bearer sk-up" {
t.Errorf("authorization = %q", gotAuth)
}
if !strings.Contains(gotBody, `"model":"ds-chat"`) {
t.Errorf("request body = %s", gotBody)
}
if len(resp.Choices) != 1 || resp.Choices[0].Message.Content != "hi" {
t.Fatalf("response = %+v", resp)
}
if resp.Usage == nil || resp.Usage.PromptTokens != 5 {
t.Errorf("usage = %+v", resp.Usage)
}
}
func TestCreateUpstreamError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"message":"bad key","type":"invalid_api_key"}}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk-bad")
_, err := client.Create(context.Background(), &Request{Model: "m"})
var ue *UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("error = %v, want *UpstreamError", err)
}
if ue.StatusCode != http.StatusUnauthorized || ue.Message != "bad key" {
t.Errorf("upstream error = %+v", ue)
}
}
func TestCreateStreamRejectsNon2xx(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":{"message":"slow down"}}`))
}))
defer upstream.Close()
client := NewClient(upstream.URL, "sk")
resp, err := client.CreateStream(context.Background(), &Request{Model: "m", Stream: true})
if err == nil {
resp.Body.Close()
t.Fatal("CreateStream succeeded on 429")
}
}
func TestStreamNext(t *testing.T) {
sse := strings.Join([]string{
`data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hel"}}]}`,
"",
`: keep-alive comment`,
`data: {"id":"1","choices":[{"index":0,"delta":{"content":"lo"}}]}`,
"",
`data: {"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`,
"",
"data: [DONE]",
"",
}, "\n")
stream := NewStream(strings.NewReader(sse))
first, err := stream.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if first.Choices[0].Delta.Content == nil || *first.Choices[0].Delta.Content != "Hel" {
t.Fatalf("first chunk = %+v", first)
}
second, err := stream.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if *second.Choices[0].Delta.Content != "lo" {
t.Fatalf("second chunk = %+v", second)
}
third, err := stream.Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if third.Choices[0].FinishReason == nil || *third.Choices[0].FinishReason != "stop" {
t.Fatalf("third chunk = %+v", third)
}
if third.Usage == nil || third.Usage.CompletionTokens != 1 {
t.Fatalf("usage chunk = %+v", third)
}
if _, err := stream.Next(); err != io.EOF {
t.Fatalf("final Next err = %v, want io.EOF", err)
}
}
+51
View File
@@ -0,0 +1,51 @@
package chatcmpl
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strings"
)
// Stream reads Server-Sent Events carrying Chat Completions chunks.
type Stream struct {
scanner *bufio.Scanner
}
// NewStream wraps a reader of upstream SSE data.
func NewStream(r io.Reader) *Stream {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
return &Stream{scanner: sc}
}
// Next returns the next chunk. It reports io.EOF once the upstream
// stream is finished (either by "[DONE]" or by closing).
func (s *Stream) Next() (*StreamChunk, error) {
for s.scanner.Scan() {
line := strings.TrimRight(s.scanner.Text(), "\r")
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
if data == "[DONE]" {
return nil, io.EOF
}
var chunk StreamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("decode upstream chunk: %w", err)
}
return &chunk, nil
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
+124
View File
@@ -0,0 +1,124 @@
// Package chatcmpl describes the OpenAI-compatible Chat Completions
// protocol spoken by upstream providers.
package chatcmpl
import "encoding/json"
// Request is a Chat Completions request body.
type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stop []string `json:"stop,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
}
// StreamOptions toggles the usage chunk at the end of a stream.
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// Message is one Chat Completions message. Content is either a string or
// a []ContentPart.
type Message struct {
Role string `json:"role"`
Content any `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ContentPart is one part of a multi-modal message.
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL references an image, usually as a data URL.
type ImageURL struct {
URL string `json:"url"`
}
// Tool is a function tool definition.
type Tool struct {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
// ToolFunction is the payload of a function tool.
type ToolFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// ToolCall is a function call requested by the model.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolCallFunction `json:"function"`
Index int `json:"index,omitempty"`
}
// ToolCallFunction holds the name and the accumulated JSON arguments.
type ToolCallFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// Response is a non-streaming Chat Completions response body.
type Response struct {
ID string `json:"id"`
Object string `json:"object"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
Error *Error `json:"error,omitempty"`
}
// Choice is one completion choice.
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
// Usage reports token counts.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// Error is an upstream error payload.
type Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code,omitempty"`
}
// StreamChunk is one SSE data payload of a streaming response.
type StreamChunk struct {
ID string `json:"id"`
Choices []ChunkChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
// ChunkChoice is one choice inside a stream chunk.
type ChunkChoice struct {
Index int `json:"index"`
Delta Delta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// Delta is the incremental payload of a stream chunk.
type Delta struct {
Role string `json:"role,omitempty"`
Content *string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
+303
View File
@@ -0,0 +1,303 @@
// Package claudecfg reads and writes Claude Code's settings.json.
// CrossRelay only touches the env keys it manages; every other key
// (theme, permissions, unrelated env vars) round-trips untouched.
package claudecfg
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// ManagedEnvKeys lists the settings.json env keys CrossRelay owns.
var ManagedEnvKeys = []string{
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_MODEL",
"ANTHROPIC_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
}
// DefaultPath returns the location of Claude Code's settings.json.
// CROSSRELAY_CLAUDE_SETTINGS overrides it (used by tests).
func DefaultPath() (string, error) {
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_CLAUDE_SETTINGS")); v != "" {
return v, nil
}
dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("locate home directory: %w", err)
}
dir = filepath.Join(home, ".claude")
}
return filepath.Join(dir, "settings.json"), nil
}
// Manager performs guarded edits on one settings.json file.
type Manager struct {
path string
backupDir string
mu sync.Mutex
}
// New builds a manager for settingsPath, storing backups in backupDir.
func New(settingsPath, backupDir string) *Manager {
return &Manager{path: settingsPath, backupDir: backupDir}
}
// ApplyOptions describes what gets injected into settings.json.
type ApplyOptions struct {
BaseURL string
Token string
Models config.Models
}
// Settings is a parsed settings.json. Raw keeps every top-level key.
type Settings struct {
Raw map[string]json.RawMessage `json:"-"`
Env map[string]string `json:"env"`
}
// UnmarshalJSON parses settings.json while keeping unknown keys.
func (s *Settings) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
s.Raw = raw
s.Env = nil
if envRaw, ok := raw["env"]; ok {
_ = json.Unmarshal(envRaw, &s.Env)
}
return nil
}
// MarshalJSON writes every original key back, env included.
func (s Settings) MarshalJSON() ([]byte, error) {
out := make(map[string]json.RawMessage, len(s.Raw)+1)
for k, v := range s.Raw {
out[k] = v
}
env, err := json.Marshal(s.Env)
if err != nil {
return nil, err
}
out["env"] = env
return json.Marshal(out)
}
// Apply injects the relay configuration, backing up the current file
// first. It returns the resulting settings.
func (m *Manager) Apply(opts ApplyOptions) (*Settings, error) {
m.mu.Lock()
defer m.mu.Unlock()
settings, err := m.load()
if err != nil {
return nil, err
}
if _, err := m.backupLocked(settings); err != nil {
return nil, err
}
if settings.Env == nil {
settings.Env = map[string]string{}
}
settings.Env["ANTHROPIC_BASE_URL"] = opts.BaseURL
settings.Env["ANTHROPIC_AUTH_TOKEN"] = opts.Token
applyModelEnv(settings.Env, opts.Models)
if err := m.saveLocked(settings); err != nil {
return nil, err
}
return settings, nil
}
// Restore copies the newest backup over settings.json and returns the
// backup file name.
func (m *Manager) Restore() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
backups, err := m.listBackupsLocked()
if err != nil {
return "", err
}
if len(backups) == 0 {
return "", fmt.Errorf("no backup found in %s", m.backupDir)
}
newest := backups[0]
data, err := os.ReadFile(filepath.Join(m.backupDir, newest.Name))
if err != nil {
return "", err
}
if err := m.writeFile(data); err != nil {
return "", err
}
return newest.Name, nil
}
// BackupInfo describes one backup file.
type BackupInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
// Backups lists backup files, newest first.
func (m *Manager) Backups() ([]BackupInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.listBackupsLocked()
}
// CurrentEnv returns the env block of the live settings.json.
func (m *Manager) CurrentEnv() (map[string]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
settings, err := m.load()
if err != nil {
return nil, err
}
return settings.Env, nil
}
func (m *Manager) load() (*Settings, error) {
data, err := os.ReadFile(m.path)
if err != nil {
if os.IsNotExist(err) {
return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil
}
return nil, err
}
if len(data) == 0 {
return &Settings{Raw: map[string]json.RawMessage{}, Env: map[string]string{}}, nil
}
var settings Settings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, fmt.Errorf("parse %s: %w", m.path, err)
}
if settings.Raw == nil {
settings.Raw = map[string]json.RawMessage{}
}
if settings.Env == nil {
settings.Env = map[string]string{}
}
return &settings, nil
}
func (m *Manager) saveLocked(settings *Settings) error {
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return m.writeFile(data)
}
func (m *Manager) writeFile(data []byte) error {
if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil {
return err
}
tmp := m.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, m.path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (m *Manager) backupLocked(settings *Settings) (string, error) {
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return "", err
}
data = append(data, '\n')
if err := os.MkdirAll(m.backupDir, 0o700); err != nil {
return "", err
}
name := fmt.Sprintf("settings-%s.json", time.Now().Format("20060102-150405"))
path := filepath.Join(m.backupDir, name)
if err := os.WriteFile(path, data, 0o600); err != nil {
return "", err
}
m.pruneBackupsLocked(20)
return name, nil
}
func (m *Manager) listBackupsLocked() ([]BackupInfo, error) {
entries, err := os.ReadDir(m.backupDir)
if err != nil {
if os.IsNotExist(err) {
return []BackupInfo{}, nil
}
return nil, err
}
out := []BackupInfo{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, BackupInfo{Name: e.Name(), Size: info.Size(), Modified: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Modified.After(out[j].Modified) })
return out, nil
}
func (m *Manager) pruneBackupsLocked(keep int) {
backups, err := m.listBackupsLocked()
if err != nil || len(backups) <= keep {
return
}
for _, b := range backups[keep:] {
_ = os.Remove(filepath.Join(m.backupDir, b.Name))
}
}
// applyModelEnv writes the model env vars for the roles the provider
// defines and removes the ones it does not.
func applyModelEnv(env map[string]string, models config.Models) {
main := firstNonEmpty(models.Sonnet, models.Default)
setOrDelete(env, "ANTHROPIC_MODEL", main)
setOrDelete(env, "ANTHROPIC_MODEL_NAME", main)
setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", firstNonEmpty(models.Sonnet, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME", firstNonEmpty(models.Sonnet, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", firstNonEmpty(models.Haiku, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME", firstNonEmpty(models.Haiku, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", firstNonEmpty(models.Opus, models.Default))
setOrDelete(env, "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME", firstNonEmpty(models.Opus, models.Default))
}
func setOrDelete(env map[string]string, key, value string) {
if value == "" {
delete(env, key)
return
}
env[key] = value
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+200
View File
@@ -0,0 +1,200 @@
package claudecfg
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
const originalSettings = `{
"theme": "dark",
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-official",
"ANTHROPIC_BASE_URL": "https://official.example.com",
"DISABLE_AUTOUPDATER": "1"
}
}
`
func newManager(t *testing.T) (*Manager, string) {
t.Helper()
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.json")
if err := os.WriteFile(settingsPath, []byte(originalSettings), 0o600); err != nil {
t.Fatalf("write settings: %v", err)
}
return New(settingsPath, filepath.Join(dir, "backups")), settingsPath
}
func readSettings(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("parse settings: %v", err)
}
return out
}
func TestApplyPreservesUnknownKeys(t *testing.T) {
mgr, path := newManager(t)
if _, err := mgr.Apply(ApplyOptions{
BaseURL: "http://127.0.0.1:8787",
Token: "crl_test",
Models: config.Models{Sonnet: "ds-chat", Haiku: "ds-haiku", Opus: "ds-opus", Default: "ds-chat"},
}); err != nil {
t.Fatalf("Apply: %v", err)
}
settings := readSettings(t, path)
if settings["theme"] != "dark" {
t.Errorf("theme lost: %v", settings["theme"])
}
env := settings["env"].(map[string]any)
if env["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787" {
t.Errorf("base url = %v", env["ANTHROPIC_BASE_URL"])
}
if env["ANTHROPIC_AUTH_TOKEN"] != "crl_test" {
t.Errorf("relay token = %v", env["ANTHROPIC_AUTH_TOKEN"])
}
if env["DISABLE_AUTOUPDATER"] != "1" {
t.Errorf("unrelated env key lost: %v", env)
}
if env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] != "ds-haiku" {
t.Errorf("haiku model = %v", env["ANTHROPIC_DEFAULT_HAIKU_MODEL"])
}
if env["ANTHROPIC_MODEL"] != "ds-chat" {
t.Errorf("main model = %v", env["ANTHROPIC_MODEL"])
}
}
func TestApplyCreatesBackupWithOriginalContent(t *testing.T) {
mgr, _ := newManager(t)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
backups, err := mgr.Backups()
if err != nil {
t.Fatalf("Backups: %v", err)
}
if len(backups) != 1 {
t.Fatalf("backups = %d, want 1", len(backups))
}
data, err := os.ReadFile(filepath.Join(mgr.backupDir, backups[0].Name))
if err != nil {
t.Fatalf("read backup: %v", err)
}
settings := map[string]any{}
if err := json.Unmarshal(data, &settings); err != nil {
t.Fatalf("parse backup: %v", err)
}
env := settings["env"].(map[string]any)
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
t.Errorf("backup holds post-apply content: %v", env)
}
}
func TestApplyIsIdempotent(t *testing.T) {
mgr, path := newManager(t)
opts := ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "ds-chat"}}
if _, err := mgr.Apply(opts); err != nil {
t.Fatalf("first Apply: %v", err)
}
afterFirst := readSettings(t, path)
if _, err := mgr.Apply(opts); err != nil {
t.Fatalf("second Apply: %v", err)
}
afterSecond := readSettings(t, path)
firstEnv, _ := json.Marshal(afterFirst["env"])
secondEnv, _ := json.Marshal(afterSecond["env"])
if string(firstEnv) != string(secondEnv) {
t.Errorf("env changed on re-apply:\n%s\n%s", firstEnv, secondEnv)
}
}
func TestRestore(t *testing.T) {
mgr, path := newManager(t)
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:8787", Token: "crl_x", Models: config.Models{Default: "m"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
name, err := mgr.Restore()
if err != nil {
t.Fatalf("Restore: %v", err)
}
if name == "" {
t.Error("Restore returned no backup name")
}
env := readSettings(t, path)["env"].(map[string]any)
if env["ANTHROPIC_BASE_URL"] != "https://official.example.com" {
t.Errorf("base url after restore = %v", env["ANTHROPIC_BASE_URL"])
}
if env["ANTHROPIC_AUTH_TOKEN"] != "sk-official" {
t.Errorf("token after restore = %v", env["ANTHROPIC_AUTH_TOKEN"])
}
}
func TestRestoreWithoutBackupFails(t *testing.T) {
mgr, _ := newManager(t)
if _, err := mgr.Restore(); err == nil {
t.Fatal("Restore succeeded without backups")
}
}
func TestApplyModelRoles(t *testing.T) {
mgr, path := newManager(t)
// Only a default: every role falls back to it.
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "only-default"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
env := readSettings(t, path)["env"].(map[string]any)
for _, key := range []string{
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
} {
if env[key] != "only-default" {
t.Errorf("%s = %v, want only-default", key, env[key])
}
}
// Empty roles remove the keys again.
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Sonnet: "s"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
env = readSettings(t, path)["env"].(map[string]any)
if _, ok := env["ANTHROPIC_DEFAULT_OPUS_MODEL"]; ok {
t.Error("opus key should be removed when the provider has no opus model")
}
}
func TestApplyCreatesMissingSettingsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings.json")
mgr := New(path, filepath.Join(dir, "backups"))
if _, err := mgr.Apply(ApplyOptions{BaseURL: "http://127.0.0.1:1", Token: "t", Models: config.Models{Default: "m"}}); err != nil {
t.Fatalf("Apply: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings.json not created: %v", err)
}
}
func TestBackupsReturnsEmptySliceNotNil(t *testing.T) {
dir := t.TempDir()
mgr := New(filepath.Join(dir, "settings.json"), filepath.Join(dir, "backups"))
backups, err := mgr.Backups()
if err != nil {
t.Fatalf("Backups: %v", err)
}
if backups == nil {
t.Fatal("Backups returned nil, which marshals to JSON null and breaks the UI")
}
if len(backups) != 0 {
t.Errorf("backups = %v, want empty", backups)
}
}
+240
View File
@@ -0,0 +1,240 @@
// Package config owns CrossRelay's own persisted state: the provider list,
// the active selections, local gateway settings and UI preferences.
package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// Version is the config.json schema version.
const Version = 1
// Listener modes for the local gateway.
const (
PortModeAuto = "auto"
PortModeFixed = "fixed"
)
// Supported UI languages.
const (
LanguageChinese = "zh-CN"
LanguageEnglish = "en-US"
)
// Models maps requested model names onto upstream model names. The role
// fields are matched by keyword (a request mentioning "haiku" uses Haiku);
// Extra holds exact requested-model overrides and wins over everything.
type Models struct {
Default string `json:"default,omitempty"`
Sonnet string `json:"sonnet,omitempty"`
Haiku string `json:"haiku,omitempty"`
Opus string `json:"opus,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// Provider is one upstream Chat Completions endpoint.
type Provider struct {
ID string `json:"id"`
Name string `json:"name"`
Preset string `json:"preset,omitempty"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models Models `json:"models"`
EnabledForClaude bool `json:"enabled_for_claude"`
CreatedAt time.Time `json:"created_at"`
}
// Gateway holds local listener settings.
type Gateway struct {
PortMode string `json:"port_mode"`
FixedPort int `json:"fixed_port,omitempty"`
}
// Config is the whole persisted configuration.
type Config struct {
Version int `json:"version"`
RelayToken string `json:"relay_token"`
Providers []Provider `json:"providers"`
ActiveClaudeID string `json:"active_claude_id,omitempty"`
Gateway Gateway `json:"gateway"`
Language string `json:"language"`
}
// ResolveModel maps the model name Claude Code asked for onto the model
// name the upstream provider should receive.
func (p *Provider) ResolveModel(requested string) string {
if p == nil {
return requested
}
if requested == "" {
return firstNonEmpty(p.Models.Default, requested)
}
// Already an upstream model name (CrossRelay wrote it into the env).
for _, m := range []string{p.Models.Default, p.Models.Sonnet, p.Models.Haiku, p.Models.Opus} {
if m != "" && m == requested {
return requested
}
}
if m, ok := p.Models.Extra[requested]; ok && m != "" {
return m
}
lower := strings.ToLower(requested)
switch {
case strings.Contains(lower, "haiku"):
return firstNonEmpty(p.Models.Haiku, p.Models.Default, requested)
case strings.Contains(lower, "opus"):
return firstNonEmpty(p.Models.Opus, p.Models.Default, requested)
case strings.Contains(lower, "sonnet"):
return firstNonEmpty(p.Models.Sonnet, p.Models.Default, requested)
}
return firstNonEmpty(p.Models.Default, requested)
}
func (p *Provider) clone() *Provider {
if p == nil {
return nil
}
out := *p
if p.Models.Extra != nil {
out.Models.Extra = make(map[string]string, len(p.Models.Extra))
for k, v := range p.Models.Extra {
out.Models.Extra[k] = v
}
}
return &out
}
func (m *Models) normalize() {
m.Default = strings.TrimSpace(m.Default)
m.Sonnet = strings.TrimSpace(m.Sonnet)
m.Haiku = strings.TrimSpace(m.Haiku)
m.Opus = strings.TrimSpace(m.Opus)
for k, v := range m.Extra {
key := strings.TrimSpace(k)
val := strings.TrimSpace(v)
if key == "" || val == "" {
delete(m.Extra, k)
continue
}
if key != k || val != v {
delete(m.Extra, k)
m.Extra[key] = val
}
}
if len(m.Extra) == 0 {
m.Extra = nil
}
}
// DefaultConfig returns a fresh configuration with a new relay token.
func DefaultConfig() *Config {
return &Config{
Version: Version,
RelayToken: NewRelayToken(),
Providers: []Provider{},
Gateway: Gateway{PortMode: PortModeAuto},
Language: LanguageChinese,
}
}
func (c *Config) normalize() {
if c.Version == 0 {
c.Version = Version
}
if strings.TrimSpace(c.RelayToken) == "" {
c.RelayToken = NewRelayToken()
}
if c.Providers == nil {
c.Providers = []Provider{}
}
for i := range c.Providers {
c.Providers[i].Models.normalize()
}
switch c.Gateway.PortMode {
case PortModeAuto, PortModeFixed:
default:
c.Gateway.PortMode = PortModeAuto
}
if c.Gateway.PortMode == PortModeFixed && (c.Gateway.FixedPort < 1 || c.Gateway.FixedPort > 65535) {
c.Gateway.PortMode = PortModeAuto
c.Gateway.FixedPort = 0
}
if c.Language != LanguageChinese && c.Language != LanguageEnglish {
c.Language = LanguageChinese
}
}
func (c *Config) clone() *Config {
out := *c
out.Providers = make([]Provider, len(c.Providers))
for i, p := range c.Providers {
out.Providers[i] = *p.clone()
}
return &out
}
func newProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("name is required")
}
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return nil, errors.New("base URL is required")
}
u, err := url.Parse(baseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("base URL must be an http(s) URL: %q", baseURL)
}
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return nil, errors.New("API key is required")
}
models.normalize()
return &Provider{
ID: newID("p_"),
Name: name,
Preset: strings.TrimSpace(preset),
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Models: models,
CreatedAt: time.Now().UTC(),
}, nil
}
// NewRelayToken generates the local token Claude Code must present.
func NewRelayToken() string {
return "crl_" + randHex(24)
}
func newID(prefix string) string {
return prefix + randHex(12)
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func marshalConfig(c *Config) ([]byte, error) {
return json.MarshalIndent(c, "", " ")
}
+146
View File
@@ -0,0 +1,146 @@
package config
import (
"strings"
"testing"
)
func TestResolveModel(t *testing.T) {
p := &Provider{
Models: Models{
Default: "ds-chat",
Sonnet: "ds-chat",
Haiku: "ds-haiku",
Opus: "ds-opus",
Extra: map[string]string{"claude-3-5-haiku-20241022": "ds-fast"},
},
}
cases := []struct {
requested string
want string
}{
{"claude-sonnet-4-5-20250929", "ds-chat"},
{"claude-3-5-haiku-20241022", "ds-fast"}, // exact Extra match wins
{"some-haiku-model", "ds-haiku"}, // keyword match
{"claude-opus-4-1", "ds-opus"},
{"ds-chat", "ds-chat"}, // already an upstream name
{"unknown-model", "ds-chat"},
{"", "ds-chat"},
}
for _, tc := range cases {
if got := p.ResolveModel(tc.requested); got != tc.want {
t.Errorf("ResolveModel(%q) = %q, want %q", tc.requested, got, tc.want)
}
}
}
func TestResolveModelWithoutDefaults(t *testing.T) {
p := &Provider{Models: Models{Sonnet: "kimi-k2"}}
if got := p.ResolveModel("claude-3-7-sonnet"); got != "kimi-k2" {
t.Errorf("sonnet request = %q, want kimi-k2", got)
}
// No default configured: unknown models pass through unchanged.
if got := p.ResolveModel("weird-model"); got != "weird-model" {
t.Errorf("unknown request = %q, want passthrough", got)
}
}
func TestStoreRoundTrip(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !strings.HasPrefix(store.Config().RelayToken, "crl_") {
t.Fatalf("relay token = %q, want crl_ prefix", store.Config().RelayToken)
}
if store.Config().Language != LanguageChinese {
t.Fatalf("default language = %q", store.Config().Language)
}
p, err := store.AddProvider("DeepSeek", "deepseek", "https://api.deepseek.com/v1", "sk-test",
Models{Default: "deepseek-chat", Sonnet: "deepseek-chat", Haiku: "deepseek-chat", Opus: "deepseek-chat"})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateClaude(p.ID); err != nil {
t.Fatalf("ActivateClaude: %v", err)
}
reloaded, err := Load()
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := reloaded.ActiveClaude(); got == nil || got.Name != "DeepSeek" {
t.Fatalf("active provider after reload = %+v", got)
}
if len(reloaded.Config().Providers) != 1 {
t.Fatalf("providers after reload = %d", len(reloaded.Config().Providers))
}
}
func TestAddProviderValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
cases := []struct {
name, baseURL, apiKey string
}{
{"", "https://x.com/v1", "sk"},
{"p", "", "sk"},
{"p", "ftp://x.com", "sk"},
{"p", "https://x.com/v1", ""},
}
for _, tc := range cases {
if _, err := store.AddProvider(tc.name, "", tc.baseURL, tc.apiKey, Models{}); err == nil {
t.Errorf("AddProvider(%q, %q, %q) succeeded, want error", tc.name, tc.baseURL, tc.apiKey)
}
}
}
func TestUpdateProviderKeepsKeyWhenEmpty(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk-secret", Models{Default: "m1"})
updated, err := store.UpdateProvider(p.ID, "p2", "", "https://x.com/v1/", "", Models{Default: "m2"})
if err != nil {
t.Fatalf("UpdateProvider: %v", err)
}
if updated.APIKey != "sk-secret" {
t.Errorf("api key = %q, want sk-secret", updated.APIKey)
}
if updated.Name != "p2" || updated.Models.Default != "m2" {
t.Errorf("update lost fields: %+v", updated)
}
if updated.BaseURL != "https://x.com/v1" {
t.Errorf("base url = %q, want trailing slash trimmed", updated.BaseURL)
}
}
func TestDeleteProviderClearsActive(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
p, _ := store.AddProvider("p", "", "https://x.com/v1", "sk", Models{})
store.ActivateClaude(p.ID)
if err := store.DeleteProvider(p.ID); err != nil {
t.Fatalf("DeleteProvider: %v", err)
}
if store.ActiveClaude() != nil {
t.Error("active provider survived deletion")
}
}
func TestSetGatewayValidation(t *testing.T) {
t.Setenv("CROSSRELAY_HOME", t.TempDir())
store, _ := Load()
if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 8787}); err != nil {
t.Fatalf("SetGateway: %v", err)
}
if err := store.SetGateway(Gateway{PortMode: "fixed", FixedPort: 0}); err == nil {
t.Error("fixed port 0 accepted")
}
if err := store.SetGateway(Gateway{PortMode: "bogus"}); err == nil {
t.Error("bogus port mode accepted")
}
if err := store.SetLanguage("fr"); err == nil {
t.Error("unknown language accepted")
}
}
+249
View File
@@ -0,0 +1,249 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Store loads and persists the configuration file. All methods are safe
// for concurrent use.
type Store struct {
mu sync.Mutex
path string
cfg *Config
}
// Dir returns the CrossRelay data directory. CROSSRELAY_HOME overrides
// the default location (~/.crossrelay), which tests rely on.
func Dir() string {
if v := strings.TrimSpace(os.Getenv("CROSSRELAY_HOME")); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil {
return ".crossrelay"
}
return filepath.Join(home, ".crossrelay")
}
// Path returns the config file location.
func Path() string { return filepath.Join(Dir(), "config.json") }
// Load reads the config file, creating defaults when it does not exist.
func Load() (*Store, error) {
s := &Store{path: Path()}
data, err := os.ReadFile(s.path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
s.cfg = DefaultConfig()
return s, s.save()
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", s.path, err)
}
cfg.normalize()
s.cfg = &cfg
return s, nil
}
// Config returns a snapshot of the configuration.
func (s *Store) Config() *Config {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg.clone()
}
// FindProvider returns a copy of the provider with the given id.
func (s *Store) FindProvider(id string) *Provider {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
return s.cfg.Providers[i].clone()
}
}
return nil
}
// ActiveClaude returns the provider currently applied to Claude Code.
func (s *Store) ActiveClaude() *Provider {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveClaudeID == "" {
return nil
}
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == s.cfg.ActiveClaudeID {
return s.cfg.Providers[i].clone()
}
}
return nil
}
// AddProvider validates and appends a provider.
func (s *Store) AddProvider(name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, err := newProvider(name, preset, baseURL, apiKey, models)
if err != nil {
return nil, err
}
s.cfg.Providers = append(s.cfg.Providers, *p)
if err := s.saveLocked(); err != nil {
return nil, err
}
return p.clone(), nil
}
// UpdateProvider replaces a provider's editable fields. An empty apiKey
// keeps the stored key so edits never wipe it by accident.
func (s *Store) UpdateProvider(id, name, preset, baseURL, apiKey string, models Models) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id)
}
existing := s.cfg.Providers[idx]
if apiKey == "" {
apiKey = existing.APIKey
}
updated, err := newProvider(name, preset, baseURL, apiKey, models)
if err != nil {
return nil, err
}
updated.ID = existing.ID
updated.EnabledForClaude = existing.EnabledForClaude
updated.CreatedAt = existing.CreatedAt
s.cfg.Providers[idx] = *updated
if err := s.saveLocked(); err != nil {
return nil, err
}
return updated.clone(), nil
}
// DeleteProvider removes a provider and clears it when active.
func (s *Store) DeleteProvider(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return fmt.Errorf("provider %q not found", id)
}
s.cfg.Providers = append(s.cfg.Providers[:idx], s.cfg.Providers[idx+1:]...)
if s.cfg.ActiveClaudeID == id {
s.cfg.ActiveClaudeID = ""
}
return s.saveLocked()
}
// ActivateClaude marks a provider as the one written into Claude Code's
// settings and records it as active.
func (s *Store) ActivateClaude(id string) (*Provider, error) {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.cfg.Providers {
if s.cfg.Providers[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return nil, fmt.Errorf("provider %q not found", id)
}
s.cfg.Providers[idx].EnabledForClaude = true
s.cfg.ActiveClaudeID = id
if err := s.saveLocked(); err != nil {
return nil, err
}
return s.cfg.Providers[idx].clone(), nil
}
// ClearActiveClaude forgets the active provider without touching the
// provider list (used after restoring the official settings).
func (s *Store) ClearActiveClaude() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.ActiveClaudeID == "" {
return nil
}
s.cfg.ActiveClaudeID = ""
return s.saveLocked()
}
// SetGateway updates listener settings.
func (s *Store) SetGateway(g Gateway) error {
g.PortMode = strings.TrimSpace(g.PortMode)
switch g.PortMode {
case PortModeAuto:
g.FixedPort = 0
case PortModeFixed:
if g.FixedPort < 1 || g.FixedPort > 65535 {
return errors.New("fixed port must be between 1 and 65535")
}
default:
return errors.New("port mode must be \"auto\" or \"fixed\"")
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.Gateway = g
return s.saveLocked()
}
// SetLanguage updates the UI language.
func (s *Store) SetLanguage(lang string) error {
if lang != LanguageChinese && lang != LanguageEnglish {
return errors.New("language must be zh-CN or en-US")
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.Language = lang
return s.saveLocked()
}
func (s *Store) save() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.saveLocked()
}
func (s *Store) saveLocked() error {
data, err := marshalConfig(s.cfg)
if err != nil {
return err
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
+396
View File
@@ -0,0 +1,396 @@
package gateway
import (
"bufio"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// fakeUpstream mimics a Chat Completions provider. It records the last
// request so tests can assert on the converted model and messages.
type fakeUpstream struct {
lastModel string
lastAuth string
lastRequest map[string]any
}
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
w.WriteHeader(http.StatusNotFound)
return
}
f.lastAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &f.lastRequest)
f.lastModel, _ = f.lastRequest["model"].(string)
stream, _ := f.lastRequest["stream"].(bool)
if stream {
w.Header().Set("Content-Type", "text/event-stream")
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hi"}}]}`)
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":" there"}}]}`)
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":2}}`)
writeSSELine(w, "data: [DONE]")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "chatcmpl-1",
"object": "chat.completion",
"model": f.lastModel,
"choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": "Hi there"}, "finish_reason": "stop"}},
"usage": map[string]any{"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11},
})
}
func writeSSELine(w http.ResponseWriter, line string) {
_, _ = io.WriteString(w, line+"\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
type testEnv struct {
gateway *httptest.Server
upstream *fakeUpstream
store *config.Store
settings string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
t.Setenv("CROSSRELAY_HOME", t.TempDir())
settingsPath := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(settingsPath, []byte(`{"theme":"dark","env":{"DISABLE_AUTOUPDATER":"1"}}`), 0o600); err != nil {
t.Fatalf("write settings: %v", err)
}
t.Setenv("CROSSRELAY_CLAUDE_SETTINGS", settingsPath)
upstream := &fakeUpstream{}
up := httptest.NewServer(upstream)
t.Cleanup(up.Close)
store, err := config.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
p, err := store.AddProvider("test", "", up.URL, "up-key", config.Models{
Default: "ds-chat",
Sonnet: "ds-chat",
Haiku: "ds-haiku",
Opus: "ds-opus",
})
if err != nil {
t.Fatalf("AddProvider: %v", err)
}
if _, err := store.ActivateClaude(p.ID); err != nil {
t.Fatalf("ActivateClaude: %v", err)
}
gw := httptest.NewServer(New(store, 18787).Handler())
t.Cleanup(gw.Close)
return &testEnv{gateway: gw, upstream: upstream, store: store, settings: settingsPath}
}
func (e *testEnv) post(t *testing.T, path string, body any, token string) *http.Response {
t.Helper()
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
reader = strings.NewReader(string(data))
}
req, err := http.NewRequest(http.MethodPost, e.gateway.URL+path, reader)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post %s: %v", path, err)
}
return resp
}
func messagesBody(model string, stream bool) map[string]any {
return map[string]any{
"model": model,
"max_tokens": 100,
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
"stream": stream,
}
}
func TestMessagesNonStream(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out["type"] != "message" || out["role"] != "assistant" {
t.Errorf("envelope = %v", out)
}
if out["model"] != "claude-sonnet-4-5" {
t.Errorf("model = %v, want requested model echoed", out["model"])
}
content := out["content"].([]any)
if len(content) != 1 {
t.Fatalf("content = %v", content)
}
block := content[0].(map[string]any)
if block["type"] != "text" || block["text"] != "Hi there" {
t.Errorf("text block = %v", block)
}
if out["stop_reason"] != "end_turn" {
t.Errorf("stop reason = %v", out["stop_reason"])
}
usage := out["usage"].(map[string]any)
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
t.Errorf("usage = %v", usage)
}
// The upstream must have seen the mapped model and the provider key.
if env.upstream.lastModel != "ds-chat" {
t.Errorf("upstream model = %q, want ds-chat", env.upstream.lastModel)
}
if env.upstream.lastAuth != "Bearer up-key" {
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
}
}
func TestMessagesHaikuRoleMapping(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-3-5-haiku-20241022", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if env.upstream.lastModel != "ds-haiku" {
t.Errorf("upstream model = %q, want ds-haiku", env.upstream.lastModel)
}
}
func TestMessagesRequiresToken(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("m", false), "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
errObj := out["error"].(map[string]any)
if errObj["type"] != "authentication_error" {
t.Errorf("error = %v", errObj)
}
}
func TestMessagesWithoutActiveProvider(t *testing.T) {
env := newTestEnv(t)
if err := env.store.ClearActiveClaude(); err != nil {
t.Fatalf("ClearActiveClaude: %v", err)
}
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestMessagesStream(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", true), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("content type = %q", ct)
}
scanner := bufio.NewScanner(resp.Body)
var events []string
var text strings.Builder
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
events = append(events, strings.TrimPrefix(line, "event: "))
}
if strings.HasPrefix(line, "data: ") && strings.Contains(line, "text_delta") {
var payload map[string]any
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
delta := payload["delta"].(map[string]any)
text.WriteString(delta["text"].(string))
}
}
}
want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop"}
if len(events) != len(want) {
t.Fatalf("events = %v, want %v", events, want)
}
for i := range want {
if events[i] != want[i] {
t.Fatalf("events = %v, want %v", events, want)
}
}
if text.String() != "Hi there" {
t.Errorf("streamed text = %q", text.String())
}
}
func TestCountTokens(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages/count_tokens", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
if out["input_tokens"].(float64) <= 0 {
t.Errorf("input tokens = %v", out["input_tokens"])
}
}
func TestApplyAndRestore(t *testing.T) {
env := newTestEnv(t)
providers := env.store.Config().Providers
if len(providers) != 1 {
t.Fatalf("providers = %d", len(providers))
}
resp := env.post(t, "/api/providers/"+providers[0].ID+"/apply", nil, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("apply status = %d", resp.StatusCode)
}
data, err := os.ReadFile(env.settings)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var settings map[string]any
_ = json.Unmarshal(data, &settings)
if settings["theme"] != "dark" {
t.Errorf("theme lost: %v", settings)
}
envVars := settings["env"].(map[string]any)
if envVars["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:18787" {
t.Errorf("base url = %v", envVars["ANTHROPIC_BASE_URL"])
}
if envVars["ANTHROPIC_AUTH_TOKEN"] != env.store.Config().RelayToken {
t.Errorf("relay token = %v", envVars["ANTHROPIC_AUTH_TOKEN"])
}
if envVars["DISABLE_AUTOUPDATER"] != "1" {
t.Errorf("unrelated env key lost: %v", envVars)
}
resp = env.post(t, "/api/restore", nil, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("restore status = %d", resp.StatusCode)
}
data, _ = os.ReadFile(env.settings)
_ = json.Unmarshal(data, &settings)
envVars = settings["env"].(map[string]any)
if _, ok := envVars["ANTHROPIC_BASE_URL"]; ok {
t.Error("base url survived restore")
}
if env.store.ActiveClaude() != nil {
t.Error("active provider survived restore")
}
}
func TestStatusAndLogs(t *testing.T) {
env := newTestEnv(t)
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
get := func(path string) map[string]any {
resp, err := http.Get(env.gateway.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return out
}
status := get("/api/status")
if status["running"] != true || status["port"].(float64) != 18787 {
t.Errorf("status = %v", status)
}
stats := status["stats"].(map[string]any)
if stats["requests_total"].(float64) < 1 {
t.Errorf("stats = %v", stats)
}
active := status["active_claude_provider"].(map[string]any)
if active["name"] != "test" {
t.Errorf("active provider = %v", active)
}
logs := get("/api/logs")
items := logs["items"].([]any)
var messageLog map[string]any
for _, item := range items {
entry := item.(map[string]any)
if entry["path"] == "/v1/messages" {
messageLog = entry
break
}
}
if messageLog == nil {
t.Fatalf("no /v1/messages entry in %v", items)
}
if messageLog["model"] != "ds-chat" {
t.Errorf("log model = %v, want the resolved model", messageLog["model"])
}
if messageLog["status"].(float64) != 200 {
t.Errorf("log status = %v", messageLog["status"])
}
}
func TestProviderCRUD(t *testing.T) {
env := newTestEnv(t)
post := func(path string, body any) *http.Response {
data, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, env.gateway.URL+path, strings.NewReader(string(data)))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post: %v", err)
}
return resp
}
resp := post("/api/providers", map[string]any{
"name": "kimi",
"base_url": "https://api.moonshot.cn/v1",
"api_key": "sk-kimi",
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d", resp.StatusCode)
}
if len(env.store.Config().Providers) != 2 {
t.Fatalf("providers = %d, want 2", len(env.store.Config().Providers))
}
resp = post("/api/providers", map[string]any{"name": "", "base_url": "x", "api_key": "y"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("invalid create status = %d, want 400", resp.StatusCode)
}
}
+195
View File
@@ -0,0 +1,195 @@
package gateway
import (
"fmt"
"net/http"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// providerInput is the provider payload accepted by the management API.
type providerInput struct {
Name string `json:"name"`
Preset string `json:"preset"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Models config.Models `json:"models"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Config()
var active any
if p := s.store.ActiveClaude(); p != nil {
active = map[string]any{
"id": p.ID,
"name": p.Name,
"base_url": p.BaseURL,
"models": p.Models,
}
}
writeJSON(w, http.StatusOK, map[string]any{
"running": true,
"version": Version,
"port": s.port,
"uptime_sec": int(time.Since(s.started).Seconds()),
"active_claude_provider": active,
"stats": s.snapshotStats(),
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
"fixed_port": cfg.Gateway.FixedPort,
})
}
func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": s.store.Config().Providers})
}
func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
var in providerInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
p, err := s.store.AddProvider(in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, p)
}
func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var in providerInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
p, err := s.store.UpdateProvider(id, in.Name, in.Preset, in.BaseURL, in.APIKey, in.Models)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
}
func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.DeleteProvider(id); err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
}
// handleApply activates a provider and writes it into Claude Code's
// settings.json.
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
p, err := s.store.ActivateClaude(id)
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
settings, err := mgr.Apply(claudecfg.ApplyOptions{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", s.port),
Token: s.store.Config().RelayToken,
Models: p.Models,
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"provider": p,
"env": settings.Env,
})
}
// handleRestore puts back the most recent backup of settings.json.
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
name, err := mgr.Restore()
if err != nil {
writeAPIError(w, http.StatusNotFound, err.Error())
return
}
_ = s.store.ClearActiveClaude()
writeJSON(w, http.StatusOK, map[string]any{"restored_from": name})
}
func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) {
mgr, err := s.claudeManager()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
items, err := mgr.Backups()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if items == nil {
items = []claudecfg.BackupInfo{}
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
cfg := s.store.Config()
writeJSON(w, http.StatusOK, map[string]any{
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
"fixed_port": cfg.Gateway.FixedPort,
})
}
type settingsInput struct {
Language string `json:"language"`
PortMode string `json:"port_mode"`
FixedPort int `json:"fixed_port"`
}
func (s *Server) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var in settingsInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if in.Language != "" {
if err := s.store.SetLanguage(in.Language); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
if in.PortMode != "" {
if err := s.store.SetGateway(config.Gateway{
PortMode: in.PortMode,
FixedPort: in.FixedPort,
}); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
}
cfg := s.store.Config()
writeJSON(w, http.StatusOK, map[string]any{
"language": cfg.Language,
"port_mode": cfg.Gateway.PortMode,
"fixed_port": cfg.Gateway.FixedPort,
})
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": s.logs.list()})
}
+34
View File
@@ -0,0 +1,34 @@
package gateway
import "sync"
// logRing keeps the most recent log entries in memory.
type logRing struct {
mu sync.Mutex
entries []LogEntry
cap int
}
func newLogRing(capacity int) *logRing {
return &logRing{cap: capacity}
}
func (r *logRing) add(entry LogEntry) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, entry)
if len(r.entries) > r.cap {
r.entries = r.entries[len(r.entries)-r.cap:]
}
}
// list returns entries newest first.
func (r *logRing) list() []LogEntry {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]LogEntry, len(r.entries))
for i, e := range r.entries {
out[len(r.entries)-1-i] = e
}
return out
}
+127
View File
@@ -0,0 +1,127 @@
package gateway
import (
"errors"
"fmt"
"io"
"net/http"
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
)
// handleMessages serves POST /v1/messages in both streaming and
// non-streaming modes.
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
var req anthropic.MessagesRequest
if err := decodeJSON(r, &req, 64<<20); err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
return
}
provider := s.store.ActiveClaude()
if provider == nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error",
"no active provider: activate one in the CrossRelay app")
return
}
model := provider.ResolveModel(req.Model)
chatReq, err := anthropic.ConvertRequest(&req, model)
if err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
rec := recordFrom(r)
rec.update(func(e *LogEntry) {
e.Model = model
e.InputTokens = anthropic.EstimateTokens(&req)
})
client := chatcmpl.NewClient(provider.BaseURL, provider.APIKey)
if req.Stream {
s.handleStream(w, r, client, chatReq, &req, model, rec)
return
}
resp, err := client.Create(r.Context(), chatReq)
if err != nil {
s.failUpstream(w, rec, err)
return
}
out := anthropic.ConvertResponse(resp, req.Model)
rec.update(func(e *LogEntry) {
e.OutputTokens = out.Usage.OutputTokens
})
writeJSON(w, http.StatusOK, out)
}
// handleStream relays a Chat Completions SSE stream as Anthropic events.
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request, client *chatcmpl.Client,
chatReq *chatcmpl.Request, req *anthropic.MessagesRequest, model string, rec *record) {
resp, err := client.CreateStream(r.Context(), chatReq)
if err != nil {
s.failUpstream(w, rec, err)
return
}
defer resp.Body.Close()
conv := anthropic.NewStreamConverter(w, req.Model, anthropic.EstimateTokens(req))
if err := conv.WriteStart(); err != nil {
return
}
stream := chatcmpl.NewStream(resp.Body)
for {
chunk, err := stream.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
conv.WriteError("api_error", err.Error())
return
}
if err := conv.OnChunk(chunk); err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
conv.WriteError("api_error", err.Error())
return
}
}
if err := conv.Finish(); err != nil {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
}
}
// handleCountTokens answers POST /v1/messages/count_tokens with a
// heuristic estimate so Claude Code's context meter keeps working.
func (s *Server) handleCountTokens(w http.ResponseWriter, r *http.Request) {
var req anthropic.MessagesRequest
if err := decodeJSON(r, &req, 64<<20); err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body: "+err.Error())
return
}
writeJSON(w, http.StatusOK, anthropic.CountTokensResponse{
InputTokens: anthropic.EstimateTokens(&req),
})
}
// failUpstream maps an upstream failure onto an Anthropic error.
func (s *Server) failUpstream(w http.ResponseWriter, rec *record, err error) {
rec.update(func(e *LogEntry) { e.Error = err.Error() })
var ue *chatcmpl.UpstreamError
if errors.As(err, &ue) {
switch {
case ue.StatusCode == http.StatusUnauthorized || ue.StatusCode == http.StatusForbidden:
writeAnthropicError(w, ue.StatusCode, "authentication_error", ue.Message)
case ue.StatusCode == http.StatusTooManyRequests:
writeAnthropicError(w, ue.StatusCode, "rate_limit_error", ue.Message)
case ue.StatusCode == http.StatusBadRequest:
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", ue.Message)
default:
writeAnthropicError(w, http.StatusBadGateway, "api_error", ue.Message)
}
return
}
writeAnthropicError(w, http.StatusBadGateway, "api_error", fmt.Sprintf("upstream request failed: %v", err))
}
+228
View File
@@ -0,0 +1,228 @@
// Package gateway serves the Anthropic Messages API to Claude Code and
// the local management API to the CrossRelay UI.
package gateway
import (
"context"
"encoding/json"
"io"
"net/http"
"path/filepath"
"sync"
"time"
"gitea.rutua.cn/kate522/CrossRelay/internal/anthropic"
"gitea.rutua.cn/kate522/CrossRelay/internal/claudecfg"
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
)
// Version is reported by /api/status.
const Version = "0.1.0"
// Stats aggregates gateway counters.
type Stats struct {
RequestsTotal int64 `json:"requests_total"`
RequestsFailed int64 `json:"requests_failed"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
// LogEntry is one recorded request.
type LogEntry struct {
Time time.Time `json:"time"`
Method string `json:"method"`
Path string `json:"path"`
Model string `json:"model,omitempty"`
Status int `json:"status"`
DurationMS int64 `json:"duration_ms"`
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
Error string `json:"error,omitempty"`
}
// Server wires the config store to the HTTP handlers.
type Server struct {
store *config.Store
port int
started time.Time
logs *logRing
statsMu sync.Mutex
stats Stats
}
// New builds a server bound to the given port.
func New(store *config.Store, port int) *Server {
return &Server{
store: store,
port: port,
started: time.Now(),
logs: newLogRing(100),
}
}
// Port returns the port the gateway listens on.
func (s *Server) Port() int { return s.port }
// Handler returns the full HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/messages", s.withAuth(s.handleMessages))
mux.HandleFunc("POST /v1/messages/count_tokens", s.withAuth(s.handleCountTokens))
mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("GET /api/providers", s.handleListProviders)
mux.HandleFunc("POST /api/providers", s.handleCreateProvider)
mux.HandleFunc("PUT /api/providers/{id}", s.handleUpdateProvider)
mux.HandleFunc("DELETE /api/providers/{id}", s.handleDeleteProvider)
mux.HandleFunc("POST /api/providers/{id}/apply", s.handleApply)
mux.HandleFunc("POST /api/restore", s.handleRestore)
mux.HandleFunc("GET /api/backups", s.handleBackups)
mux.HandleFunc("GET /api/settings", s.handleGetSettings)
mux.HandleFunc("PUT /api/settings", s.handleUpdateSettings)
mux.HandleFunc("GET /api/logs", s.handleLogs)
return s.withLogging(mux)
}
// withAuth protects the Anthropic-compatible endpoints with the local
// relay token.
func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := s.store.Config().RelayToken
if token != "" {
got := bearerToken(r)
if got == "" {
got = r.Header.Get("x-api-key")
}
if got != token {
writeAnthropicError(w, http.StatusUnauthorized, "authentication_error", "invalid relay token")
return
}
}
next(w, r)
}
}
func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
return ""
}
// record carries per-request fields from handlers to the logging
// middleware.
type record struct {
mu sync.Mutex
entry LogEntry
}
type recordKey struct{}
func recordFrom(r *http.Request) *record {
rec, _ := r.Context().Value(recordKey{}).(*record)
return rec
}
func (rec *record) update(fn func(*LogEntry)) {
if rec == nil {
return
}
rec.mu.Lock()
defer rec.mu.Unlock()
fn(&rec.entry)
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &record{entry: LogEntry{
Time: start,
Method: r.Method,
Path: r.URL.Path,
}}
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(ww, r.WithContext(context.WithValue(r.Context(), recordKey{}, rec)))
rec.mu.Lock()
rec.entry.Status = ww.status
rec.entry.DurationMS = time.Since(start).Milliseconds()
entry := rec.entry
rec.mu.Unlock()
s.logs.add(entry)
s.statsMu.Lock()
s.stats.RequestsTotal++
if entry.Status >= 400 {
s.stats.RequestsFailed++
}
s.stats.InputTokens += int64(entry.InputTokens)
s.stats.OutputTokens += int64(entry.OutputTokens)
s.statsMu.Unlock()
})
}
type statusWriter struct {
http.ResponseWriter
status int
wroteHeader bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.status = code
w.wroteHeader = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// Flush keeps streaming responses unbuffered.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (s *Server) claudeManager() (*claudecfg.Manager, error) {
path, err := claudecfg.DefaultPath()
if err != nil {
return nil, err
}
return claudecfg.New(path, filepath.Join(config.Dir(), "backups")), nil
}
func (s *Server) snapshotStats() Stats {
s.statsMu.Lock()
defer s.statsMu.Unlock()
return s.stats
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeAnthropicError(w http.ResponseWriter, status int, errType, message string) {
writeJSON(w, status, anthropic.ErrorResponse{
Type: "error",
Error: anthropic.Detail{Type: errType, Message: message},
})
}
func writeAPIError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": message})
}
func decodeJSON(r *http.Request, v any, limit int64) error {
dec := json.NewDecoder(io.LimitReader(r.Body, limit))
return dec.Decode(v)
}
+56
View File
@@ -0,0 +1,56 @@
// Package presets holds the built-in upstream provider templates.
package presets
import "gitea.rutua.cn/kate522/CrossRelay/internal/config"
// Preset is a ready-made provider template. Model names reflect each
// vendor's catalog as of 2026-10 and can be edited per provider.
type Preset struct {
Key string `json:"key"`
BaseURL string `json:"base_url"`
Models config.Models `json:"models"`
}
// All lists the built-in presets.
var All = []Preset{
{
Key: "deepseek",
BaseURL: "https://api.deepseek.com/v1",
Models: config.Models{
Default: "deepseek-chat",
Sonnet: "deepseek-chat",
Haiku: "deepseek-chat",
Opus: "deepseek-chat",
},
},
{
Key: "moonshot",
BaseURL: "https://api.moonshot.cn/v1",
Models: config.Models{
Default: "kimi-k2-0905-preview",
Sonnet: "kimi-k2-0905-preview",
Haiku: "kimi-k2-0905-preview",
Opus: "kimi-k2-0905-preview",
},
},
{
Key: "zhipu",
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
Models: config.Models{
Default: "glm-4.6",
Sonnet: "glm-4.6",
Haiku: "glm-4.6",
Opus: "glm-4.6",
},
},
}
// ByKey looks up a preset by its key.
func ByKey(key string) (Preset, bool) {
for _, p := range All {
if p.Key == key {
return p, true
}
}
return Preset{}, false
}