Files
CrossRelay/internal/anthropic/convert_test.go
T
kate522 f8acf31ebc 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
2026-10-18 17:19:13 +08:00

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")
}
}