ant_test.go

   1package ant
   2
   3import (
   4	"context"
   5	"encoding/json"
   6	"io"
   7	"net/http"
   8	"strings"
   9	"testing"
  10
  11	"shelley.exe.dev/llm"
  12)
  13
  14func TestIsClaudeModel(t *testing.T) {
  15	tests := []struct {
  16		name     string
  17		userName string
  18		want     bool
  19	}{
  20		{"claude model", "claude", true},
  21		{"sonnet model", "sonnet", true},
  22		{"opus model", "opus", true},
  23		{"unknown model", "gpt-4", false},
  24		{"empty string", "", false},
  25		{"random string", "random", false},
  26	}
  27
  28	for _, tt := range tests {
  29		t.Run(tt.name, func(t *testing.T) {
  30			if got := IsClaudeModel(tt.userName); got != tt.want {
  31				t.Errorf("IsClaudeModel(%q) = %v, want %v", tt.userName, got, tt.want)
  32			}
  33		})
  34	}
  35}
  36
  37func TestClaudeModelName(t *testing.T) {
  38	tests := []struct {
  39		name     string
  40		userName string
  41		want     string
  42	}{
  43		{"claude model", "claude", Claude45Sonnet},
  44		{"sonnet model", "sonnet", Claude45Sonnet},
  45		{"opus model", "opus", Claude45Opus},
  46		{"unknown model", "gpt-4", ""},
  47		{"empty string", "", ""},
  48		{"random string", "random", ""},
  49	}
  50
  51	for _, tt := range tests {
  52		t.Run(tt.name, func(t *testing.T) {
  53			if got := ClaudeModelName(tt.userName); got != tt.want {
  54				t.Errorf("ClaudeModelName(%q) = %v, want %v", tt.userName, got, tt.want)
  55			}
  56		})
  57	}
  58}
  59
  60func TestTokenContextWindow(t *testing.T) {
  61	tests := []struct {
  62		name  string
  63		model string
  64		want  int
  65	}{
  66		{"default model", "", 200000},
  67		{"Claude37Sonnet", Claude37Sonnet, 200000},
  68		{"Claude4Sonnet", Claude4Sonnet, 200000},
  69		{"Claude45Sonnet", Claude45Sonnet, 200000},
  70		{"Claude45Haiku", Claude45Haiku, 200000},
  71		{"Claude45Opus", Claude45Opus, 200000},
  72		{"unknown model", "unknown-model", 200000},
  73	}
  74
  75	for _, tt := range tests {
  76		t.Run(tt.name, func(t *testing.T) {
  77			s := &Service{Model: tt.model}
  78			if got := s.TokenContextWindow(); got != tt.want {
  79				t.Errorf("TokenContextWindow() = %v, want %v", got, tt.want)
  80			}
  81		})
  82	}
  83}
  84
  85func TestMaxImageDimension(t *testing.T) {
  86	s := &Service{}
  87	want := 2000
  88	if got := s.MaxImageDimension(); got != want {
  89		t.Errorf("MaxImageDimension() = %v, want %v", got, want)
  90	}
  91}
  92
  93func TestToLLMUsage(t *testing.T) {
  94	tests := []struct {
  95		name string
  96		u    usage
  97		want llm.Usage
  98	}{
  99		{
 100			name: "empty usage",
 101			u:    usage{},
 102			want: llm.Usage{},
 103		},
 104		{
 105			name: "full usage",
 106			u: usage{
 107				InputTokens:              100,
 108				CacheCreationInputTokens: 50,
 109				CacheReadInputTokens:     25,
 110				OutputTokens:             200,
 111				CostUSD:                  0.05,
 112			},
 113			want: llm.Usage{
 114				InputTokens:              100,
 115				CacheCreationInputTokens: 50,
 116				CacheReadInputTokens:     25,
 117				OutputTokens:             200,
 118				CostUSD:                  0.05,
 119			},
 120		},
 121	}
 122
 123	for _, tt := range tests {
 124		t.Run(tt.name, func(t *testing.T) {
 125			got := toLLMUsage(tt.u)
 126			if got != tt.want {
 127				t.Errorf("toLLMUsage() = %+v, want %+v", got, tt.want)
 128			}
 129		})
 130	}
 131}
 132
 133func TestToLLMContent(t *testing.T) {
 134	text := "hello world"
 135	tests := []struct {
 136		name string
 137		c    content
 138		want llm.Content
 139	}{
 140		{
 141			name: "text content",
 142			c: content{
 143				Type: "text",
 144				Text: &text,
 145			},
 146			want: llm.Content{
 147				Type: llm.ContentTypeText,
 148				Text: "hello world",
 149			},
 150		},
 151		{
 152			name: "thinking content",
 153			c: content{
 154				Type:      "thinking",
 155				Thinking:  "thinking content",
 156				Signature: "signature",
 157			},
 158			want: llm.Content{
 159				Type:      llm.ContentTypeThinking,
 160				Thinking:  "thinking content",
 161				Signature: "signature",
 162			},
 163		},
 164		{
 165			name: "redacted thinking content",
 166			c: content{
 167				Type:      "redacted_thinking",
 168				Data:      "redacted data",
 169				Signature: "signature",
 170			},
 171			want: llm.Content{
 172				Type:      llm.ContentTypeRedactedThinking,
 173				Data:      "redacted data",
 174				Signature: "signature",
 175			},
 176		},
 177		{
 178			name: "tool use content",
 179			c: content{
 180				Type:      "tool_use",
 181				ID:        "tool-id",
 182				ToolName:  "bash",
 183				ToolInput: json.RawMessage(`{"command":"ls"}`),
 184			},
 185			want: llm.Content{
 186				Type:      llm.ContentTypeToolUse,
 187				ID:        "tool-id",
 188				ToolName:  "bash",
 189				ToolInput: json.RawMessage(`{"command":"ls"}`),
 190			},
 191		},
 192		{
 193			name: "tool result content",
 194			c: content{
 195				Type:      "tool_result",
 196				ToolUseID: "tool-use-id",
 197				ToolError: true,
 198			},
 199			want: llm.Content{
 200				Type:      llm.ContentTypeToolResult,
 201				ToolUseID: "tool-use-id",
 202				ToolError: true,
 203			},
 204		},
 205	}
 206
 207	for _, tt := range tests {
 208		t.Run(tt.name, func(t *testing.T) {
 209			got := toLLMContent(tt.c)
 210			if got.Type != tt.want.Type {
 211				t.Errorf("toLLMContent().Type = %v, want %v", got.Type, tt.want.Type)
 212			}
 213			if got.Text != tt.want.Text {
 214				t.Errorf("toLLMContent().Text = %v, want %v", got.Text, tt.want.Text)
 215			}
 216			if got.Thinking != tt.want.Thinking {
 217				t.Errorf("toLLMContent().Thinking = %v, want %v", got.Thinking, tt.want.Thinking)
 218			}
 219			if got.Signature != tt.want.Signature {
 220				t.Errorf("toLLMContent().Signature = %v, want %v", got.Signature, tt.want.Signature)
 221			}
 222			if got.Data != tt.want.Data {
 223				t.Errorf("toLLMContent().Data = %v, want %v", got.Data, tt.want.Data)
 224			}
 225			if got.ID != tt.want.ID {
 226				t.Errorf("toLLMContent().ID = %v, want %v", got.ID, tt.want.ID)
 227			}
 228			if got.ToolName != tt.want.ToolName {
 229				t.Errorf("toLLMContent().ToolName = %v, want %v", got.ToolName, tt.want.ToolName)
 230			}
 231			if string(got.ToolInput) != string(tt.want.ToolInput) {
 232				t.Errorf("toLLMContent().ToolInput = %v, want %v", string(got.ToolInput), string(tt.want.ToolInput))
 233			}
 234			if got.ToolUseID != tt.want.ToolUseID {
 235				t.Errorf("toLLMContent().ToolUseID = %v, want %v", got.ToolUseID, tt.want.ToolUseID)
 236			}
 237			if got.ToolError != tt.want.ToolError {
 238				t.Errorf("toLLMContent().ToolError = %v, want %v", got.ToolError, tt.want.ToolError)
 239			}
 240		})
 241	}
 242}
 243
 244func TestToLLMResponse(t *testing.T) {
 245	text := "Hello, world!"
 246	resp := &response{
 247		ID:         "msg_123",
 248		Type:       "message",
 249		Role:       "assistant",
 250		Model:      Claude45Sonnet,
 251		Content:    []content{{Type: "text", Text: &text}},
 252		StopReason: "end_turn",
 253		Usage: usage{
 254			InputTokens:  100,
 255			OutputTokens: 50,
 256			CostUSD:      0.01,
 257		},
 258	}
 259
 260	got := toLLMResponse(resp)
 261	if got.ID != "msg_123" {
 262		t.Errorf("toLLMResponse().ID = %v, want %v", got.ID, "msg_123")
 263	}
 264	if got.Type != "message" {
 265		t.Errorf("toLLMResponse().Type = %v, want %v", got.Type, "message")
 266	}
 267	if got.Role != llm.MessageRoleAssistant {
 268		t.Errorf("toLLMResponse().Role = %v, want %v", got.Role, llm.MessageRoleAssistant)
 269	}
 270	if got.Model != Claude45Sonnet {
 271		t.Errorf("toLLMResponse().Model = %v, want %v", got.Model, Claude45Sonnet)
 272	}
 273	if len(got.Content) != 1 {
 274		t.Errorf("toLLMResponse().Content length = %v, want %v", len(got.Content), 1)
 275	}
 276	if got.Content[0].Type != llm.ContentTypeText {
 277		t.Errorf("toLLMResponse().Content[0].Type = %v, want %v", got.Content[0].Type, llm.ContentTypeText)
 278	}
 279	if got.Content[0].Text != "Hello, world!" {
 280		t.Errorf("toLLMResponse().Content[0].Text = %v, want %v", got.Content[0].Text, "Hello, world!")
 281	}
 282	if got.StopReason != llm.StopReasonEndTurn {
 283		t.Errorf("toLLMResponse().StopReason = %v, want %v", got.StopReason, llm.StopReasonEndTurn)
 284	}
 285	if got.Usage.InputTokens != 100 {
 286		t.Errorf("toLLMResponse().Usage.InputTokens = %v, want %v", got.Usage.InputTokens, 100)
 287	}
 288	if got.Usage.OutputTokens != 50 {
 289		t.Errorf("toLLMResponse().Usage.OutputTokens = %v, want %v", got.Usage.OutputTokens, 50)
 290	}
 291	if got.Usage.CostUSD != 0.01 {
 292		t.Errorf("toLLMResponse().Usage.CostUSD = %v, want %v", got.Usage.CostUSD, 0.01)
 293	}
 294}
 295
 296func TestFromLLMToolUse(t *testing.T) {
 297	tests := []struct {
 298		name string
 299		tu   *llm.ToolUse
 300		want *toolUse
 301	}{
 302		{
 303			name: "nil tool use",
 304			tu:   nil,
 305			want: nil,
 306		},
 307		{
 308			name: "valid tool use",
 309			tu: &llm.ToolUse{
 310				ID:   "tool-id",
 311				Name: "bash",
 312			},
 313			want: &toolUse{
 314				ID:   "tool-id",
 315				Name: "bash",
 316			},
 317		},
 318	}
 319
 320	for _, tt := range tests {
 321		t.Run(tt.name, func(t *testing.T) {
 322			got := fromLLMToolUse(tt.tu)
 323			if tt.want == nil && got != nil {
 324				t.Errorf("fromLLMToolUse() = %v, want nil", got)
 325			} else if tt.want != nil && got == nil {
 326				t.Errorf("fromLLMToolUse() = nil, want %v", tt.want)
 327			} else if tt.want != nil && got != nil {
 328				if got.ID != tt.want.ID || got.Name != tt.want.Name {
 329					t.Errorf("fromLLMToolUse() = %v, want %v", got, tt.want)
 330				}
 331			}
 332		})
 333	}
 334}
 335
 336func TestFromLLMMessage(t *testing.T) {
 337	text := "Hello, world!"
 338	msg := llm.Message{
 339		Role: llm.MessageRoleAssistant,
 340		Content: []llm.Content{
 341			{
 342				Type: llm.ContentTypeText,
 343				Text: text,
 344			},
 345		},
 346		ToolUse: &llm.ToolUse{
 347			ID:   "tool-id",
 348			Name: "bash",
 349		},
 350	}
 351
 352	got := fromLLMMessage(msg)
 353	if got.Role != "assistant" {
 354		t.Errorf("fromLLMMessage().Role = %v, want %v", got.Role, "assistant")
 355	}
 356	if len(got.Content) != 1 {
 357		t.Errorf("fromLLMMessage().Content length = %v, want %v", len(got.Content), 1)
 358	}
 359	if got.Content[0].Type != "text" {
 360		t.Errorf("fromLLMMessage().Content[0].Type = %v, want %v", got.Content[0].Type, "text")
 361	}
 362	if *got.Content[0].Text != text {
 363		t.Errorf("fromLLMMessage().Content[0].Text = %v, want %v", *got.Content[0].Text, text)
 364	}
 365	if got.ToolUse == nil {
 366		t.Errorf("fromLLMMessage().ToolUse = nil, want not nil")
 367	} else {
 368		if got.ToolUse.ID != "tool-id" {
 369			t.Errorf("fromLLMMessage().ToolUse.ID = %v, want %v", got.ToolUse.ID, "tool-id")
 370		}
 371		if got.ToolUse.Name != "bash" {
 372			t.Errorf("fromLLMMessage().ToolUse.Name = %v, want %v", got.ToolUse.Name, "bash")
 373		}
 374	}
 375}
 376
 377func TestFromLLMToolChoice(t *testing.T) {
 378	tests := []struct {
 379		name string
 380		tc   *llm.ToolChoice
 381		want *toolChoice
 382	}{
 383		{
 384			name: "nil tool choice",
 385			tc:   nil,
 386			want: nil,
 387		},
 388		{
 389			name: "auto tool choice",
 390			tc: &llm.ToolChoice{
 391				Type: llm.ToolChoiceTypeAuto,
 392			},
 393			want: &toolChoice{
 394				Type: "auto",
 395			},
 396		},
 397		{
 398			name: "tool tool choice",
 399			tc: &llm.ToolChoice{
 400				Type: llm.ToolChoiceTypeTool,
 401				Name: "bash",
 402			},
 403			want: &toolChoice{
 404				Type: "tool",
 405				Name: "bash",
 406			},
 407		},
 408	}
 409
 410	for _, tt := range tests {
 411		t.Run(tt.name, func(t *testing.T) {
 412			got := fromLLMToolChoice(tt.tc)
 413			if tt.want == nil && got != nil {
 414				t.Errorf("fromLLMToolChoice() = %v, want nil", got)
 415			} else if tt.want != nil && got == nil {
 416				t.Errorf("fromLLMToolChoice() = nil, want %v", tt.want)
 417			} else if tt.want != nil && got != nil {
 418				if got.Type != tt.want.Type {
 419					t.Errorf("fromLLMToolChoice().Type = %v, want %v", got.Type, tt.want.Type)
 420				}
 421				if got.Name != tt.want.Name {
 422					t.Errorf("fromLLMToolChoice().Name = %v, want %v", got.Name, tt.want.Name)
 423				}
 424			}
 425		})
 426	}
 427}
 428
 429func TestFromLLMTool(t *testing.T) {
 430	tool := &llm.Tool{
 431		Name:        "bash",
 432		Description: "Execute bash commands",
 433		InputSchema: json.RawMessage(`{"type":"object"}`),
 434		Cache:       true,
 435	}
 436
 437	got := fromLLMTool(tool)
 438	if got.Name != "bash" {
 439		t.Errorf("fromLLMTool().Name = %v, want %v", got.Name, "bash")
 440	}
 441	if got.Description != "Execute bash commands" {
 442		t.Errorf("fromLLMTool().Description = %v, want %v", got.Description, "Execute bash commands")
 443	}
 444	if string(got.InputSchema) != `{"type":"object"}` {
 445		t.Errorf("fromLLMTool().InputSchema = %v, want %v", string(got.InputSchema), `{"type":"object"}`)
 446	}
 447	if string(got.CacheControl) != `{"type":"ephemeral"}` {
 448		t.Errorf("fromLLMTool().CacheControl = %v, want %v", string(got.CacheControl), `{"type":"ephemeral"}`)
 449	}
 450}
 451
 452func TestFromLLMSystem(t *testing.T) {
 453	sys := llm.SystemContent{
 454		Text:  "You are a helpful assistant",
 455		Type:  "text",
 456		Cache: true,
 457	}
 458
 459	got := fromLLMSystem(sys)
 460	if got.Text != "You are a helpful assistant" {
 461		t.Errorf("fromLLMSystem().Text = %v, want %v", got.Text, "You are a helpful assistant")
 462	}
 463	if got.Type != "text" {
 464		t.Errorf("fromLLMSystem().Type = %v, want %v", got.Type, "text")
 465	}
 466	if string(got.CacheControl) != `{"type":"ephemeral"}` {
 467		t.Errorf("fromLLMSystem().CacheControl = %v, want %v", string(got.CacheControl), `{"type":"ephemeral"}`)
 468	}
 469}
 470
 471func TestMapped(t *testing.T) {
 472	// Test the mapped function with a simple example
 473	input := []int{1, 2, 3, 4, 5}
 474	expected := []int{2, 4, 6, 8, 10}
 475
 476	got := mapped(input, func(x int) int { return x * 2 })
 477
 478	if len(got) != len(expected) {
 479		t.Errorf("mapped() length = %v, want %v", len(got), len(expected))
 480	}
 481
 482	for i, v := range got {
 483		if v != expected[i] {
 484			t.Errorf("mapped()[%d] = %v, want %v", i, v, expected[i])
 485		}
 486	}
 487}
 488
 489func TestUsageAdd(t *testing.T) {
 490	u1 := usage{
 491		InputTokens:              100,
 492		CacheCreationInputTokens: 50,
 493		CacheReadInputTokens:     25,
 494		OutputTokens:             200,
 495		CostUSD:                  0.05,
 496	}
 497
 498	u2 := usage{
 499		InputTokens:              150,
 500		CacheCreationInputTokens: 75,
 501		CacheReadInputTokens:     30,
 502		OutputTokens:             300,
 503		CostUSD:                  0.07,
 504	}
 505
 506	u1.Add(u2)
 507
 508	if u1.InputTokens != 250 {
 509		t.Errorf("usage.Add() InputTokens = %v, want %v", u1.InputTokens, 250)
 510	}
 511	if u1.CacheCreationInputTokens != 125 {
 512		t.Errorf("usage.Add() CacheCreationInputTokens = %v, want %v", u1.CacheCreationInputTokens, 125)
 513	}
 514	if u1.CacheReadInputTokens != 55 {
 515		t.Errorf("usage.Add() CacheReadInputTokens = %v, want %v", u1.CacheReadInputTokens, 55)
 516	}
 517	if u1.OutputTokens != 500 {
 518		t.Errorf("usage.Add() OutputTokens = %v, want %v", u1.OutputTokens, 500)
 519	}
 520
 521	// Use a small epsilon for floating point comparison
 522	const epsilon = 1e-10
 523	expectedCost := 0.12
 524	if abs(u1.CostUSD-expectedCost) > epsilon {
 525		t.Errorf("usage.Add() CostUSD = %v, want %v", u1.CostUSD, expectedCost)
 526	}
 527}
 528
 529func abs(x float64) float64 {
 530	if x < 0 {
 531		return -x
 532	}
 533	return x
 534}
 535
 536func TestFromLLMRequest(t *testing.T) {
 537	s := &Service{
 538		Model:     Claude45Sonnet,
 539		MaxTokens: 1000,
 540	}
 541
 542	req := &llm.Request{
 543		Messages: []llm.Message{
 544			{
 545				Role: llm.MessageRoleUser,
 546				Content: []llm.Content{
 547					{
 548						Type: llm.ContentTypeText,
 549						Text: "Hello, world!",
 550					},
 551				},
 552			},
 553		},
 554		ToolChoice: &llm.ToolChoice{
 555			Type: llm.ToolChoiceTypeAuto,
 556		},
 557		Tools: []*llm.Tool{
 558			{
 559				Name:        "bash",
 560				Description: "Execute bash commands",
 561				InputSchema: json.RawMessage(`{"type":"object"}`),
 562			},
 563		},
 564		System: []llm.SystemContent{
 565			{
 566				Text: "You are a helpful assistant",
 567			},
 568		},
 569	}
 570
 571	got := s.fromLLMRequest(req)
 572
 573	if got.Model != Claude45Sonnet {
 574		t.Errorf("fromLLMRequest().Model = %v, want %v", got.Model, Claude45Sonnet)
 575	}
 576	if got.MaxTokens != 1000 {
 577		t.Errorf("fromLLMRequest().MaxTokens = %v, want %v", got.MaxTokens, 1000)
 578	}
 579	if len(got.Messages) != 1 {
 580		t.Errorf("fromLLMRequest().Messages length = %v, want %v", len(got.Messages), 1)
 581	}
 582	if got.ToolChoice == nil {
 583		t.Errorf("fromLLMRequest().ToolChoice = nil, want not nil")
 584	} else if got.ToolChoice.Type != "auto" {
 585		t.Errorf("fromLLMRequest().ToolChoice.Type = %v, want %v", got.ToolChoice.Type, "auto")
 586	}
 587	if len(got.Tools) != 1 {
 588		t.Errorf("fromLLMRequest().Tools length = %v, want %v", len(got.Tools), 1)
 589	} else if got.Tools[0].Name != "bash" {
 590		t.Errorf("fromLLMRequest().Tools[0].Name = %v, want %v", got.Tools[0].Name, "bash")
 591	}
 592	if len(got.System) != 1 {
 593		t.Errorf("fromLLMRequest().System length = %v, want %v", len(got.System), 1)
 594	} else if got.System[0].Text != "You are a helpful assistant" {
 595		t.Errorf("fromLLMRequest().System[0].Text = %v, want %v", got.System[0].Text, "You are a helpful assistant")
 596	}
 597}
 598
 599func TestConfigDetails(t *testing.T) {
 600	tests := []struct {
 601		name    string
 602		service *Service
 603		want    map[string]string
 604	}{
 605		{
 606			name: "default values",
 607			service: &Service{
 608				APIKey: "test-key",
 609			},
 610			want: map[string]string{
 611				"url":             DefaultURL,
 612				"model":           DefaultModel,
 613				"has_api_key_set": "true",
 614			},
 615		},
 616		{
 617			name: "custom values",
 618			service: &Service{
 619				URL:    "https://custom.anthropic.com/v1/messages",
 620				Model:  Claude45Opus,
 621				APIKey: "test-key",
 622			},
 623			want: map[string]string{
 624				"url":             "https://custom.anthropic.com/v1/messages",
 625				"model":           Claude45Opus,
 626				"has_api_key_set": "true",
 627			},
 628		},
 629		{
 630			name: "no api key",
 631			service: &Service{
 632				APIKey: "",
 633			},
 634			want: map[string]string{
 635				"url":             DefaultURL,
 636				"model":           DefaultModel,
 637				"has_api_key_set": "false",
 638			},
 639		},
 640	}
 641
 642	for _, tt := range tests {
 643		t.Run(tt.name, func(t *testing.T) {
 644			got := tt.service.ConfigDetails()
 645			for key, wantValue := range tt.want {
 646				if gotValue, ok := got[key]; !ok {
 647					t.Errorf("ConfigDetails() missing key %q", key)
 648				} else if gotValue != wantValue {
 649					t.Errorf("ConfigDetails()[%q] = %v, want %v", key, gotValue, wantValue)
 650				}
 651			}
 652		})
 653	}
 654}
 655
 656func TestDo(t *testing.T) {
 657	// Create a mock HTTP client that returns a predefined response
 658	mockResponse := `{
 659		"id": "msg_123",
 660		"type": "message",
 661		"role": "assistant",
 662		"model": "claude-sonnet-4-5-20250929",
 663		"content": [
 664			{
 665				"type": "text",
 666				"text": "Hello, world!"
 667			}
 668		],
 669		"stop_reason": "end_turn",
 670		"usage": {
 671			"input_tokens": 100,
 672			"output_tokens": 50,
 673			"cost_usd": 0.01
 674		}
 675	}`
 676
 677	// Create a service with a mock HTTP client
 678	client := &http.Client{
 679		Transport: &mockHTTPTransport{responseBody: mockResponse, statusCode: 200},
 680	}
 681
 682	s := &Service{
 683		APIKey: "test-key",
 684		HTTPC:  client,
 685	}
 686
 687	// Create a request
 688	req := &llm.Request{
 689		Messages: []llm.Message{
 690			{
 691				Role: llm.MessageRoleUser,
 692				Content: []llm.Content{
 693					{
 694						Type: llm.ContentTypeText,
 695						Text: "Hello, Claude!",
 696					},
 697				},
 698			},
 699		},
 700	}
 701
 702	// Call Do
 703	resp, err := s.Do(context.Background(), req)
 704	if err != nil {
 705		t.Fatalf("Do() error = %v, want nil", err)
 706	}
 707
 708	// Check the response
 709	if resp == nil {
 710		t.Fatalf("Do() response = nil, want not nil")
 711	}
 712	if resp.ID != "msg_123" {
 713		t.Errorf("Do() response ID = %v, want %v", resp.ID, "msg_123")
 714	}
 715	if resp.Role != llm.MessageRoleAssistant {
 716		t.Errorf("Do() response Role = %v, want %v", resp.Role, llm.MessageRoleAssistant)
 717	}
 718	if len(resp.Content) != 1 {
 719		t.Errorf("Do() response Content length = %v, want %v", len(resp.Content), 1)
 720	} else if resp.Content[0].Text != "Hello, world!" {
 721		t.Errorf("Do() response Content[0].Text = %v, want %v", resp.Content[0].Text, "Hello, world!")
 722	}
 723	if resp.Usage.InputTokens != 100 {
 724		t.Errorf("Do() response Usage.InputTokens = %v, want %v", resp.Usage.InputTokens, 100)
 725	}
 726	if resp.Usage.OutputTokens != 50 {
 727		t.Errorf("Do() response Usage.OutputTokens = %v, want %v", resp.Usage.OutputTokens, 50)
 728	}
 729}
 730
 731// mockHTTPTransport is a mock HTTP transport for testing
 732type mockHTTPTransport struct {
 733	responseBody string
 734	statusCode   int
 735}
 736
 737func (m *mockHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 738	resp := &http.Response{
 739		StatusCode: m.statusCode,
 740		Body:       io.NopCloser(strings.NewReader(m.responseBody)),
 741		Header:     make(http.Header),
 742	}
 743	resp.Header.Set("content-type", "application/json")
 744	return resp, nil
 745}
 746
 747func TestFromLLMContent(t *testing.T) {
 748	text := "hello world"
 749	toolInput := json.RawMessage(`{"command":"ls"}`)
 750
 751	tests := []struct {
 752		name string
 753		c    llm.Content
 754		want content
 755	}{
 756		{
 757			name: "text content",
 758			c: llm.Content{
 759				Type: llm.ContentTypeText,
 760				Text: "hello world",
 761			},
 762			want: content{
 763				Type: "text",
 764				Text: &text,
 765			},
 766		},
 767		{
 768			name: "thinking content",
 769			c: llm.Content{
 770				Type:      llm.ContentTypeThinking,
 771				Thinking:  "thinking content",
 772				Signature: "signature",
 773			},
 774			want: content{
 775				Type:      "thinking",
 776				Thinking:  "thinking content",
 777				Signature: "signature",
 778			},
 779		},
 780		{
 781			name: "redacted thinking content",
 782			c: llm.Content{
 783				Type:      llm.ContentTypeRedactedThinking,
 784				Data:      "redacted data",
 785				Signature: "signature",
 786			},
 787			want: content{
 788				Type:      "redacted_thinking",
 789				Data:      "redacted data",
 790				Signature: "signature",
 791			},
 792		},
 793		{
 794			name: "tool use content",
 795			c: llm.Content{
 796				Type:      llm.ContentTypeToolUse,
 797				ID:        "tool-id",
 798				ToolName:  "bash",
 799				ToolInput: toolInput,
 800			},
 801			want: content{
 802				Type:      "tool_use",
 803				ID:        "tool-id",
 804				ToolName:  "bash",
 805				ToolInput: toolInput,
 806			},
 807		},
 808		{
 809			name: "tool result content",
 810			c: llm.Content{
 811				Type:      llm.ContentTypeToolResult,
 812				ToolUseID: "tool-use-id",
 813				ToolError: true,
 814			},
 815			want: content{
 816				Type:      "tool_result",
 817				ToolUseID: "tool-use-id",
 818				ToolError: true,
 819			},
 820		},
 821		{
 822			name: "image content as text",
 823			c: llm.Content{
 824				Type:      llm.ContentTypeText,
 825				MediaType: "image/jpeg",
 826				Data:      "base64image",
 827			},
 828			want: content{
 829				Type:   "image",
 830				Source: json.RawMessage(`{"type":"base64","media_type":"image/jpeg","data":"base64image"}`),
 831			},
 832		},
 833		{
 834			name: "tool result with nested content",
 835			c: llm.Content{
 836				Type:      llm.ContentTypeToolResult,
 837				ToolUseID: "tool-use-id",
 838				ToolResult: []llm.Content{
 839					{
 840						Type: llm.ContentTypeText,
 841						Text: "nested text",
 842					},
 843				},
 844			},
 845			want: content{
 846				Type:      "tool_result",
 847				ToolUseID: "tool-use-id",
 848				ToolResult: []content{
 849					{
 850						Type: "text",
 851						Text: &[]string{"nested text"}[0],
 852					},
 853				},
 854			},
 855		},
 856		{
 857			name: "tool result with nested image content",
 858			c: llm.Content{
 859				Type:      llm.ContentTypeToolResult,
 860				ToolUseID: "tool-use-id",
 861				ToolResult: []llm.Content{
 862					{
 863						Type:      llm.ContentTypeText,
 864						MediaType: "image/png",
 865						Data:      "base64image",
 866					},
 867				},
 868			},
 869			want: content{
 870				Type:      "tool_result",
 871				ToolUseID: "tool-use-id",
 872				ToolResult: []content{
 873					{
 874						Type:   "image",
 875						Source: json.RawMessage(`{"type":"base64","media_type":"image/png","data":"base64image"}`),
 876					},
 877				},
 878			},
 879		},
 880	}
 881
 882	for _, tt := range tests {
 883		t.Run(tt.name, func(t *testing.T) {
 884			got := fromLLMContent(tt.c)
 885
 886			// Compare basic fields
 887			if got.Type != tt.want.Type {
 888				t.Errorf("fromLLMContent().Type = %v, want %v", got.Type, tt.want.Type)
 889			}
 890
 891			if got.ID != tt.want.ID {
 892				t.Errorf("fromLLMContent().ID = %v, want %v", got.ID, tt.want.ID)
 893			}
 894
 895			if got.Thinking != tt.want.Thinking {
 896				t.Errorf("fromLLMContent().Thinking = %v, want %v", got.Thinking, tt.want.Thinking)
 897			}
 898
 899			if got.Signature != tt.want.Signature {
 900				t.Errorf("fromLLMContent().Signature = %v, want %v", got.Signature, tt.want.Signature)
 901			}
 902
 903			if got.Data != tt.want.Data {
 904				t.Errorf("fromLLMContent().Data = %v, want %v", got.Data, tt.want.Data)
 905			}
 906
 907			if got.ToolName != tt.want.ToolName {
 908				t.Errorf("fromLLMContent().ToolName = %v, want %v", got.ToolName, tt.want.ToolName)
 909			}
 910
 911			if string(got.ToolInput) != string(tt.want.ToolInput) {
 912				t.Errorf("fromLLMContent().ToolInput = %v, want %v", string(got.ToolInput), string(tt.want.ToolInput))
 913			}
 914
 915			if got.ToolUseID != tt.want.ToolUseID {
 916				t.Errorf("fromLLMContent().ToolUseID = %v, want %v", got.ToolUseID, tt.want.ToolUseID)
 917			}
 918
 919			if got.ToolError != tt.want.ToolError {
 920				t.Errorf("fromLLMContent().ToolError = %v, want %v", got.ToolError, tt.want.ToolError)
 921			}
 922
 923			// Compare text field
 924			if tt.want.Text != nil {
 925				if got.Text == nil {
 926					t.Errorf("fromLLMContent().Text = nil, want %v", *tt.want.Text)
 927				} else if *got.Text != *tt.want.Text {
 928					t.Errorf("fromLLMContent().Text = %v, want %v", *got.Text, *tt.want.Text)
 929				}
 930			} else if got.Text != nil {
 931				t.Errorf("fromLLMContent().Text = %v, want nil", *got.Text)
 932			}
 933
 934			// Compare source field (for image content)
 935			if len(tt.want.Source) > 0 {
 936				if string(got.Source) != string(tt.want.Source) {
 937					t.Errorf("fromLLMContent().Source = %v, want %v", string(got.Source), string(tt.want.Source))
 938				}
 939			}
 940
 941			// Compare tool result length
 942			if len(got.ToolResult) != len(tt.want.ToolResult) {
 943				t.Errorf("fromLLMContent().ToolResult length = %v, want %v", len(got.ToolResult), len(tt.want.ToolResult))
 944			} else if len(tt.want.ToolResult) > 0 {
 945				// Compare each tool result item
 946				for i, tr := range tt.want.ToolResult {
 947					if got.ToolResult[i].Type != tr.Type {
 948						t.Errorf("fromLLMContent().ToolResult[%d].Type = %v, want %v", i, got.ToolResult[i].Type, tr.Type)
 949					}
 950					if tr.Text != nil {
 951						if got.ToolResult[i].Text == nil {
 952							t.Errorf("fromLLMContent().ToolResult[%d].Text = nil, want %v", i, *tr.Text)
 953						} else if *got.ToolResult[i].Text != *tr.Text {
 954							t.Errorf("fromLLMContent().ToolResult[%d].Text = %v, want %v", i, *got.ToolResult[i].Text, *tr.Text)
 955						}
 956					}
 957					if len(tr.Source) > 0 {
 958						if string(got.ToolResult[i].Source) != string(tr.Source) {
 959							t.Errorf("fromLLMContent().ToolResult[%d].Source = %v, want %v", i, string(got.ToolResult[i].Source), string(tr.Source))
 960						}
 961					}
 962				}
 963			}
 964		})
 965	}
 966}
 967
 968func TestInverted(t *testing.T) {
 969	// Test normal case
 970	m := map[string]int{
 971		"a": 1,
 972		"b": 2,
 973		"c": 3,
 974	}
 975
 976	want := map[int]string{
 977		1: "a",
 978		2: "b",
 979		3: "c",
 980	}
 981
 982	got := inverted(m)
 983
 984	if len(got) != len(want) {
 985		t.Errorf("inverted() length = %v, want %v", len(got), len(want))
 986	}
 987
 988	for k, v := range want {
 989		if gotV, ok := got[k]; !ok {
 990			t.Errorf("inverted() missing key %v", k)
 991		} else if gotV != v {
 992			t.Errorf("inverted()[%v] = %v, want %v", k, gotV, v)
 993		}
 994	}
 995
 996	// Test panic case with duplicate values
 997	defer func() {
 998		if r := recover(); r == nil {
 999			t.Errorf("inverted() should panic with duplicate values")
1000		}
1001	}()
1002
1003	m2 := map[string]int{
1004		"a": 1,
1005		"b": 1, // duplicate value
1006	}
1007
1008	inverted(m2)
1009}
1010
1011func TestToLLMContentWithNestedToolResults(t *testing.T) {
1012	text := "nested text"
1013	nestedContent := content{
1014		Type: "text",
1015		Text: &text,
1016	}
1017
1018	c := content{
1019		Type:      "tool_result",
1020		ToolUseID: "tool-use-id",
1021		ToolResult: []content{
1022			nestedContent,
1023		},
1024	}
1025
1026	got := toLLMContent(c)
1027
1028	if got.Type != llm.ContentTypeToolResult {
1029		t.Errorf("toLLMContent().Type = %v, want %v", got.Type, llm.ContentTypeToolResult)
1030	}
1031
1032	if got.ToolUseID != "tool-use-id" {
1033		t.Errorf("toLLMContent().ToolUseID = %v, want %v", got.ToolUseID, "tool-use-id")
1034	}
1035
1036	if len(got.ToolResult) != 1 {
1037		t.Errorf("toLLMContent().ToolResult length = %v, want %v", len(got.ToolResult), 1)
1038	} else {
1039		if got.ToolResult[0].Type != llm.ContentTypeText {
1040			t.Errorf("toLLMContent().ToolResult[0].Type = %v, want %v", got.ToolResult[0].Type, llm.ContentTypeText)
1041		}
1042		if got.ToolResult[0].Text != "nested text" {
1043			t.Errorf("toLLMContent().ToolResult[0].Text = %v, want %v", got.ToolResult[0].Text, "nested text")
1044		}
1045	}
1046}
1047
1048func TestDoClientError(t *testing.T) {
1049	// Create a mock HTTP client that returns a client error
1050	mockResponse := `{"error": "bad request"}`
1051
1052	// Create a service with a mock HTTP client
1053	client := &http.Client{
1054		Transport: &mockHTTPTransport{responseBody: mockResponse, statusCode: 400},
1055	}
1056
1057	s := &Service{
1058		APIKey: "test-key",
1059		HTTPC:  client,
1060	}
1061
1062	// Create a request
1063	req := &llm.Request{
1064		Messages: []llm.Message{
1065			{
1066				Role: llm.MessageRoleUser,
1067				Content: []llm.Content{
1068					{
1069						Type: llm.ContentTypeText,
1070						Text: "Hello, Claude!",
1071					},
1072				},
1073			},
1074		},
1075	}
1076
1077	// Call Do - should fail immediately
1078	resp, err := s.Do(context.Background(), req)
1079	if err == nil {
1080		t.Fatalf("Do() error = nil, want error")
1081	}
1082
1083	if resp != nil {
1084		t.Errorf("Do() response = %v, want nil", resp)
1085	}
1086}
1087
1088func TestServiceConfigDetails(t *testing.T) {
1089	tests := []struct {
1090		name    string
1091		service *Service
1092		want    map[string]string
1093	}{
1094		{
1095			name: "default values",
1096			service: &Service{
1097				APIKey: "test-key",
1098			},
1099			want: map[string]string{
1100				"url":             DefaultURL,
1101				"model":           DefaultModel,
1102				"has_api_key_set": "true",
1103			},
1104		},
1105		{
1106			name: "custom values",
1107			service: &Service{
1108				APIKey: "test-key",
1109				URL:    "https://custom-url.com",
1110				Model:  "custom-model",
1111			},
1112			want: map[string]string{
1113				"url":             "https://custom-url.com",
1114				"model":           "custom-model",
1115				"has_api_key_set": "true",
1116			},
1117		},
1118		{
1119			name: "empty api key",
1120			service: &Service{
1121				APIKey: "",
1122			},
1123			want: map[string]string{
1124				"url":             DefaultURL,
1125				"model":           DefaultModel,
1126				"has_api_key_set": "false",
1127			},
1128		},
1129	}
1130
1131	for _, tt := range tests {
1132		t.Run(tt.name, func(t *testing.T) {
1133			got := tt.service.ConfigDetails()
1134
1135			for key, wantValue := range tt.want {
1136				if gotValue, ok := got[key]; !ok {
1137					t.Errorf("ConfigDetails() missing key %v", key)
1138				} else if gotValue != wantValue {
1139					t.Errorf("ConfigDetails()[%v] = %v, want %v", key, gotValue, wantValue)
1140				}
1141			}
1142		})
1143	}
1144}
1145
1146func TestDoStartTimeEndTime(t *testing.T) {
1147	// Create a mock HTTP client that returns a predefined response
1148	mockResponse := `{
1149		"id": "msg_123",
1150		"type": "message",
1151		"role": "assistant",
1152		"model": "claude-sonnet-4-5-20250929",
1153		"content": [
1154			{
1155				"type": "text",
1156				"text": "Hello, world!"
1157			}
1158		],
1159		"stop_reason": "end_turn",
1160		"usage": {
1161			"input_tokens": 100,
1162			"output_tokens": 50,
1163			"cost_usd": 0.01
1164		}
1165	}`
1166
1167	// Create a service with a mock HTTP client
1168	client := &http.Client{
1169		Transport: &mockHTTPTransport{responseBody: mockResponse, statusCode: 200},
1170	}
1171
1172	s := &Service{
1173		APIKey: "test-key",
1174		HTTPC:  client,
1175	}
1176
1177	// Create a request
1178	req := &llm.Request{
1179		Messages: []llm.Message{
1180			{
1181				Role: llm.MessageRoleUser,
1182				Content: []llm.Content{
1183					{
1184						Type: llm.ContentTypeText,
1185						Text: "Hello, Claude!",
1186					},
1187				},
1188			},
1189		},
1190	}
1191
1192	// Call Do
1193	resp, err := s.Do(context.Background(), req)
1194	if err != nil {
1195		t.Fatalf("Do() error = %v, want nil", err)
1196	}
1197
1198	// Check the response
1199	if resp == nil {
1200		t.Fatalf("Do() response = nil, want not nil")
1201	}
1202
1203	// Check that StartTime and EndTime are set
1204	if resp.StartTime == nil {
1205		t.Error("Do() response StartTime = nil, want not nil")
1206	}
1207
1208	if resp.EndTime == nil {
1209		t.Error("Do() response EndTime = nil, want not nil")
1210	}
1211
1212	// Check that EndTime is after StartTime
1213	if resp.StartTime != nil && resp.EndTime != nil {
1214		if resp.EndTime.Before(*resp.StartTime) {
1215			t.Error("Do() response EndTime should be after StartTime")
1216		}
1217	}
1218}