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