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}