- 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
242 lines
7.9 KiB
Go
242 lines
7.9 KiB
Go
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")
|
|
}
|
|
}
|