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