1package oai
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 "time"
10
11 "github.com/sashabaranov/go-openai"
12 "shelley.exe.dev/llm"
13)
14
15func TestToRoleFromString(t *testing.T) {
16 tests := []struct {
17 name string
18 role string
19 expected llm.MessageRole
20 }{
21 {
22 name: "assistant role",
23 role: "assistant",
24 expected: llm.MessageRoleAssistant,
25 },
26 {
27 name: "user role",
28 role: "user",
29 expected: llm.MessageRoleUser,
30 },
31 {
32 name: "tool role maps to assistant",
33 role: "tool",
34 expected: llm.MessageRoleAssistant,
35 },
36 {
37 name: "system role maps to assistant",
38 role: "system",
39 expected: llm.MessageRoleAssistant,
40 },
41 {
42 name: "function role maps to assistant",
43 role: "function",
44 expected: llm.MessageRoleAssistant,
45 },
46 {
47 name: "unknown role defaults to user",
48 role: "unknown",
49 expected: llm.MessageRoleUser,
50 },
51 {
52 name: "empty role defaults to user",
53 role: "",
54 expected: llm.MessageRoleUser,
55 },
56 }
57
58 for _, tt := range tests {
59 t.Run(tt.name, func(t *testing.T) {
60 result := toRoleFromString(tt.role)
61 if result != tt.expected {
62 t.Errorf("toRoleFromString(%q) = %v, expected %v", tt.role, result, tt.expected)
63 }
64 })
65 }
66}
67
68func TestToStopReason(t *testing.T) {
69 tests := []struct {
70 name string
71 reason string
72 expected llm.StopReason
73 }{
74 {
75 name: "stop reason",
76 reason: "stop",
77 expected: llm.StopReasonStopSequence,
78 },
79 {
80 name: "length reason",
81 reason: "length",
82 expected: llm.StopReasonMaxTokens,
83 },
84 {
85 name: "tool_calls reason",
86 reason: "tool_calls",
87 expected: llm.StopReasonToolUse,
88 },
89 {
90 name: "function_call reason",
91 reason: "function_call",
92 expected: llm.StopReasonToolUse,
93 },
94 {
95 name: "content_filter reason",
96 reason: "content_filter",
97 expected: llm.StopReasonStopSequence,
98 },
99 {
100 name: "unknown reason defaults to stop_sequence",
101 reason: "unknown",
102 expected: llm.StopReasonStopSequence,
103 },
104 {
105 name: "empty reason defaults to stop_sequence",
106 reason: "",
107 expected: llm.StopReasonStopSequence,
108 },
109 }
110
111 for _, tt := range tests {
112 t.Run(tt.name, func(t *testing.T) {
113 result := toStopReason(tt.reason)
114 if result != tt.expected {
115 t.Errorf("toStopReason(%q) = %v, expected %v", tt.reason, result, tt.expected)
116 }
117 })
118 }
119}
120
121func TestTokenContextWindow(t *testing.T) {
122 tests := []struct {
123 name string
124 model Model
125 expected int
126 }{
127 {
128 name: "GPT-4.1 model",
129 model: GPT41,
130 expected: 200000,
131 },
132 {
133 name: "GPT-4o model",
134 model: GPT4o,
135 expected: 128000,
136 },
137 {
138 name: "GPT-4o Mini model",
139 model: GPT4oMini,
140 expected: 128000,
141 },
142 {
143 name: "O3 model",
144 model: O3,
145 expected: 200000,
146 },
147 {
148 name: "O4-mini model",
149 model: O4Mini,
150 expected: 128000, // o4-mini-2025-04-16 is not in the special cases, so it defaults to 128k
151 },
152 {
153 name: "Gemini 2.5 Flash model",
154 model: Gemini25Flash,
155 expected: 128000,
156 },
157 {
158 name: "Gemini 2.5 Pro model",
159 model: Gemini25Pro,
160 expected: 128000,
161 },
162 {
163 name: "Together Deepseek V3 model",
164 model: TogetherDeepseekV3,
165 expected: 128000,
166 },
167 {
168 name: "Together Qwen3 model",
169 model: TogetherQwen3,
170 expected: 128000, // Qwen/Qwen3-235B-A22B-fp8-tput is not in the special cases, so it defaults to 128k
171 },
172 {
173 name: "Default model for unknown",
174 model: Model{ModelName: "unknown-model"},
175 expected: 128000,
176 },
177 }
178
179 for _, tt := range tests {
180 t.Run(tt.name, func(t *testing.T) {
181 service := &Service{Model: tt.model}
182 result := service.TokenContextWindow()
183 if result != tt.expected {
184 t.Errorf("TokenContextWindow() for model %s = %d, expected %d", tt.model.ModelName, result, tt.expected)
185 }
186 })
187 }
188}
189
190func TestMaxImageDimension(t *testing.T) {
191 // Test both Service and ResponsesService
192 model := GPT41
193
194 // Test Service.MaxImageDimension
195 service := &Service{Model: model}
196 result := service.MaxImageDimension()
197 if result != 0 {
198 t.Errorf("Service.MaxImageDimension() = %d, expected 0", result)
199 }
200
201 // Test ResponsesService.MaxImageDimension
202 responsesService := &ResponsesService{Model: model}
203 result2 := responsesService.MaxImageDimension()
204 if result2 != 0 {
205 t.Errorf("ResponsesService.MaxImageDimension() = %d, expected 0", result2)
206 }
207}
208
209func TestUseSimplifiedPatch(t *testing.T) {
210 // Test Service.UseSimplifiedPatch
211 tests := []struct {
212 name string
213 model Model
214 expected bool
215 }{
216 {
217 name: "Default model (false)",
218 model: GPT41,
219 expected: false,
220 },
221 {
222 name: "Model with UseSimplifiedPatch=true",
223 model: Model{UseSimplifiedPatch: true},
224 expected: true,
225 },
226 }
227
228 for _, tt := range tests {
229 t.Run(tt.name, func(t *testing.T) {
230 service := &Service{Model: tt.model}
231 result := service.UseSimplifiedPatch()
232 if result != tt.expected {
233 t.Errorf("Service.UseSimplifiedPatch() = %v, expected %v", result, tt.expected)
234 }
235 })
236 }
237}
238
239func TestConfigDetails(t *testing.T) {
240 model := GPT41
241 service := &Service{Model: model}
242
243 details := service.ConfigDetails()
244
245 expectedKeys := []string{"base_url", "model_name", "full_url", "api_key_env", "has_api_key_set"}
246 for _, key := range expectedKeys {
247 if _, exists := details[key]; !exists {
248 t.Errorf("ConfigDetails() missing key: %s", key)
249 }
250 }
251
252 if details["model_name"] != model.ModelName {
253 t.Errorf("ConfigDetails()[model_name] = %s, expected %s", details["model_name"], model.ModelName)
254 }
255
256 if details["base_url"] != model.URL {
257 t.Errorf("ConfigDetails()[base_url] = %s, expected %s", details["base_url"], model.URL)
258 }
259
260 if details["api_key_env"] != model.APIKeyEnv {
261 t.Errorf("ConfigDetails()[api_key_env] = %s, expected %s", details["api_key_env"], model.APIKeyEnv)
262 }
263}
264
265func TestOAIResponsesServiceUseSimplifiedPatch(t *testing.T) {
266 model := Model{UseSimplifiedPatch: true}
267 service := &ResponsesService{Model: model}
268
269 result := service.UseSimplifiedPatch()
270 if !result {
271 t.Errorf("ResponsesService.UseSimplifiedPatch() = %v, expected true", result)
272 }
273}
274
275func TestOAIResponsesServiceConfigDetails(t *testing.T) {
276 model := GPT41
277 service := &ResponsesService{Model: model}
278
279 details := service.ConfigDetails()
280
281 expectedKeys := []string{"base_url", "model_name", "full_url", "api_key_env", "has_api_key_set"}
282 for _, key := range expectedKeys {
283 if _, exists := details[key]; !exists {
284 t.Errorf("ConfigDetails() missing key: %s", key)
285 }
286 }
287
288 // Check that the full_url is different (should be /responses instead of /chat/completions)
289 if details["full_url"] != model.URL+"/responses" {
290 t.Errorf("ConfigDetails()[full_url] = %s, expected %s", details["full_url"], model.URL+"/responses")
291 }
292}
293
294func TestFromLLMContent(t *testing.T) {
295 // Test text content
296 textContent := llm.Content{
297 Type: llm.ContentTypeText,
298 Text: "Hello, world!",
299 }
300 text, toolCalls := fromLLMContent(textContent)
301 if text != "Hello, world!" {
302 t.Errorf("fromLLMContent(text) text = %q, expected %q", text, "Hello, world!")
303 }
304 if len(toolCalls) != 0 {
305 t.Errorf("fromLLMContent(text) toolCalls length = %d, expected 0", len(toolCalls))
306 }
307
308 // Test tool use content
309 toolUseContent := llm.Content{
310 Type: llm.ContentTypeToolUse,
311 ID: "tool-call-1",
312 ToolName: "get_weather",
313 ToolInput: json.RawMessage(`{"location": "New York"}`),
314 }
315 text, toolCalls = fromLLMContent(toolUseContent)
316 if text != "" {
317 t.Errorf("fromLLMContent(toolUse) text = %q, expected empty string", text)
318 }
319 if len(toolCalls) != 1 {
320 t.Errorf("fromLLMContent(toolUse) toolCalls length = %d, expected 1", len(toolCalls))
321 } else {
322 tc := toolCalls[0]
323 if tc.Type != openai.ToolTypeFunction {
324 t.Errorf("toolCall.Type = %q, expected %q", tc.Type, openai.ToolTypeFunction)
325 }
326 if tc.ID != "tool-call-1" {
327 t.Errorf("toolCall.ID = %q, expected %q", tc.ID, "tool-call-1")
328 }
329 if tc.Function.Name != "get_weather" {
330 t.Errorf("toolCall.Function.Name = %q, expected %q", tc.Function.Name, "get_weather")
331 }
332 if tc.Function.Arguments != `{"location": "New York"}` {
333 t.Errorf("toolCall.Function.Arguments = %q, expected %q", tc.Function.Arguments, `{"location": "New York"}`)
334 }
335 }
336
337 // Test tool result content
338 toolResultContent := llm.Content{
339 Type: llm.ContentTypeToolResult,
340 ToolResult: []llm.Content{
341 {Type: llm.ContentTypeText, Text: "Sunny"},
342 {Type: llm.ContentTypeText, Text: "72°F"},
343 },
344 }
345 text, toolCalls = fromLLMContent(toolResultContent)
346 expectedText := "Sunny\n72°F"
347 if text != expectedText {
348 t.Errorf("fromLLMContent(toolResult) text = %q, expected %q", text, expectedText)
349 }
350 if len(toolCalls) != 0 {
351 t.Errorf("fromLLMContent(toolResult) toolCalls length = %d, expected 0", len(toolCalls))
352 }
353
354 // Test default case (thinking content)
355 thinkingContent := llm.Content{
356 Type: llm.ContentTypeThinking,
357 Text: "Thinking about the answer...",
358 }
359 text, toolCalls = fromLLMContent(thinkingContent)
360 if text != "Thinking about the answer..." {
361 t.Errorf("fromLLMContent(thinking) text = %q, expected %q", text, "Thinking about the answer...")
362 }
363 if len(toolCalls) != 0 {
364 t.Errorf("fromLLMContent(thinking) toolCalls length = %d, expected 0", len(toolCalls))
365 }
366}
367
368func TestToRawLLMContent(t *testing.T) {
369 content := toRawLLMContent("test text")
370 if content.Type != llm.ContentTypeText {
371 t.Errorf("toRawLLMContent().Type = %v, expected %v", content.Type, llm.ContentTypeText)
372 }
373 if content.Text != "test text" {
374 t.Errorf("toRawLLMContent().Text = %q, expected %q", content.Text, "test text")
375 }
376}
377
378func TestToToolCallLLMContent(t *testing.T) {
379 // Test with ID
380 toolCall := openai.ToolCall{
381 ID: "tool-call-1",
382 Type: openai.ToolTypeFunction,
383 Function: openai.FunctionCall{
384 Name: "get_weather",
385 Arguments: `{"location": "New York"}`,
386 },
387 }
388 content := toToolCallLLMContent(toolCall)
389 if content.Type != llm.ContentTypeToolUse {
390 t.Errorf("toToolCallLLMContent().Type = %v, expected %v", content.Type, llm.ContentTypeToolUse)
391 }
392 if content.ID != "tool-call-1" {
393 t.Errorf("toToolCallLLMContent().ID = %q, expected %q", content.ID, "tool-call-1")
394 }
395 if content.ToolName != "get_weather" {
396 t.Errorf("toToolCallLLMContent().ToolName = %q, expected %q", content.ToolName, "get_weather")
397 }
398 if string(content.ToolInput) != `{"location": "New York"}` {
399 t.Errorf("toToolCallLLMContent().ToolInput = %q, expected %q", string(content.ToolInput), `{"location": "New York"}`)
400 }
401
402 // Test without ID (should generate one)
403 toolCallNoID := openai.ToolCall{
404 Type: openai.ToolTypeFunction,
405 Function: openai.FunctionCall{
406 Name: "get_weather",
407 Arguments: `{"location": "New York"}`,
408 },
409 }
410 contentNoID := toToolCallLLMContent(toolCallNoID)
411 if contentNoID.ID != "tc_get_weather" {
412 t.Errorf("toToolCallLLMContent() with no ID = %q, expected %q", contentNoID.ID, "tc_get_weather")
413 }
414}
415
416func TestToToolResultLLMContent(t *testing.T) {
417 msg := openai.ChatCompletionMessage{
418 Role: "tool",
419 Content: "Sunny weather",
420 ToolCallID: "tool-call-1",
421 }
422 content := toToolResultLLMContent(msg)
423 if content.Type != llm.ContentTypeToolResult {
424 t.Errorf("toToolResultLLMContent().Type = %v, expected %v", content.Type, llm.ContentTypeToolResult)
425 }
426 if content.ToolUseID != "tool-call-1" {
427 t.Errorf("toToolResultLLMContent().ToolUseID = %q, expected %q", content.ToolUseID, "tool-call-1")
428 }
429 if len(content.ToolResult) != 1 {
430 t.Errorf("toToolResultLLMContent().ToolResult length = %d, expected 1", len(content.ToolResult))
431 } else {
432 result := content.ToolResult[0]
433 if result.Type != llm.ContentTypeText {
434 t.Errorf("ToolResult[0].Type = %v, expected %v", result.Type, llm.ContentTypeText)
435 }
436 if result.Text != "Sunny weather" {
437 t.Errorf("ToolResult[0].Text = %q, expected %q", result.Text, "Sunny weather")
438 }
439 }
440 if content.ToolError != false {
441 t.Errorf("toToolResultLLMContent().ToolError = %v, expected false", content.ToolError)
442 }
443}
444
445func TestToLLMContents(t *testing.T) {
446 // Test tool response message
447 toolMsg := openai.ChatCompletionMessage{
448 Role: "tool",
449 Content: "Sunny weather",
450 ToolCallID: "tool-call-1",
451 }
452 contents := toLLMContents(toolMsg)
453 if len(contents) != 1 {
454 t.Errorf("toLLMContents(toolMsg) length = %d, expected 1", len(contents))
455 } else {
456 content := contents[0]
457 if content.Type != llm.ContentTypeToolResult {
458 t.Errorf("toLLMContents(toolMsg)[0].Type = %v, expected %v", content.Type, llm.ContentTypeToolResult)
459 }
460 }
461
462 // Test regular message with text
463 textMsg := openai.ChatCompletionMessage{
464 Role: "assistant",
465 Content: "Hello, world!",
466 }
467 contents = toLLMContents(textMsg)
468 if len(contents) != 1 {
469 t.Errorf("toLLMContents(textMsg) length = %d, expected 1", len(contents))
470 } else {
471 content := contents[0]
472 if content.Type != llm.ContentTypeText {
473 t.Errorf("toLLMContents(textMsg)[0].Type = %v, expected %v", content.Type, llm.ContentTypeText)
474 }
475 if content.Text != "Hello, world!" {
476 t.Errorf("toLLMContents(textMsg)[0].Text = %q, expected %q", content.Text, "Hello, world!")
477 }
478 }
479
480 // Test message with tool calls
481 toolCallMsg := openai.ChatCompletionMessage{
482 Role: "assistant",
483 Content: "",
484 ToolCalls: []openai.ToolCall{
485 {
486 ID: "tool-call-1",
487 Type: openai.ToolTypeFunction,
488 Function: openai.FunctionCall{
489 Name: "get_weather",
490 Arguments: `{"location": "New York"}`,
491 },
492 },
493 },
494 }
495 contents = toLLMContents(toolCallMsg)
496 if len(contents) != 1 {
497 t.Errorf("toLLMContents(toolCallMsg) length = %d, expected 1", len(contents))
498 } else {
499 content := contents[0]
500 if content.Type != llm.ContentTypeToolUse {
501 t.Errorf("toLLMContents(toolCallMsg)[0].Type = %v, expected %v", content.Type, llm.ContentTypeToolUse)
502 }
503 }
504
505 // Test empty message
506 emptyMsg := openai.ChatCompletionMessage{
507 Role: "assistant",
508 Content: "",
509 }
510 contents = toLLMContents(emptyMsg)
511 if len(contents) != 1 {
512 t.Errorf("toLLMContents(emptyMsg) length = %d, expected 1", len(contents))
513 } else {
514 content := contents[0]
515 if content.Type != llm.ContentTypeText {
516 t.Errorf("toLLMContents(emptyMsg)[0].Type = %v, expected %v", content.Type, llm.ContentTypeText)
517 }
518 if content.Text != "" {
519 t.Errorf("toLLMContents(emptyMsg)[0].Text = %q, expected empty string", content.Text)
520 }
521 }
522}
523
524func TestFromLLMToolChoice(t *testing.T) {
525 // Test nil tool choice
526 result := fromLLMToolChoice(nil)
527 if result != nil {
528 t.Errorf("fromLLMToolChoice(nil) = %v, expected nil", result)
529 }
530
531 // Test specific tool choice
532 toolChoice := &llm.ToolChoice{
533 Type: llm.ToolChoiceTypeTool,
534 Name: "get_weather",
535 }
536 result = fromLLMToolChoice(toolChoice)
537 if toolChoiceResult, ok := result.(openai.ToolChoice); !ok {
538 t.Errorf("fromLLMToolChoice(tool) result type = %T, expected openai.ToolChoice", result)
539 } else {
540 if toolChoiceResult.Type != openai.ToolTypeFunction {
541 t.Errorf("ToolChoice.Type = %q, expected %q", toolChoiceResult.Type, openai.ToolTypeFunction)
542 }
543 if toolChoiceResult.Function.Name != "get_weather" {
544 t.Errorf("ToolChoice.Function.Name = %q, expected %q", toolChoiceResult.Function.Name, "get_weather")
545 }
546 }
547
548 // Test auto tool choice
549 autoChoice := &llm.ToolChoice{Type: llm.ToolChoiceTypeAuto}
550 result = fromLLMToolChoice(autoChoice)
551 if result != "auto" {
552 t.Errorf("fromLLMToolChoice(auto) = %v, expected %q", result, "auto")
553 }
554
555 // Test any tool choice
556 anyChoice := &llm.ToolChoice{Type: llm.ToolChoiceTypeAny}
557 result = fromLLMToolChoice(anyChoice)
558 if result != "any" {
559 t.Errorf("fromLLMToolChoice(any) = %v, expected %q", result, "any")
560 }
561
562 // Test none tool choice
563 noneChoice := &llm.ToolChoice{Type: llm.ToolChoiceTypeNone}
564 result = fromLLMToolChoice(noneChoice)
565 if result != "none" {
566 t.Errorf("fromLLMToolChoice(none) = %v, expected %q", result, "none")
567 }
568}
569
570func TestFromLLMMessage(t *testing.T) {
571 // Test regular message with text content
572 textMsg := llm.Message{
573 Role: llm.MessageRoleUser,
574 Content: []llm.Content{
575 {Type: llm.ContentTypeText, Text: "Hello, world!"},
576 },
577 }
578 messages := fromLLMMessage(textMsg)
579 if len(messages) != 1 {
580 t.Errorf("fromLLMMessage(textMsg) length = %d, expected 1", len(messages))
581 } else {
582 msg := messages[0]
583 if msg.Role != "user" {
584 t.Errorf("message.Role = %q, expected %q", msg.Role, "user")
585 }
586 if msg.Content != "Hello, world!" {
587 t.Errorf("message.Content = %q, expected %q", msg.Content, "Hello, world!")
588 }
589 if len(msg.ToolCalls) != 0 {
590 t.Errorf("message.ToolCalls length = %d, expected 0", len(msg.ToolCalls))
591 }
592 }
593
594 // Test assistant message with tool use
595 toolMsg := llm.Message{
596 Role: llm.MessageRoleAssistant,
597 Content: []llm.Content{
598 {
599 Type: llm.ContentTypeToolUse,
600 ID: "tool-call-1",
601 ToolName: "get_weather",
602 ToolInput: json.RawMessage(`{"location": "New York"}`),
603 },
604 },
605 }
606 messages = fromLLMMessage(toolMsg)
607 if len(messages) != 1 {
608 t.Errorf("fromLLMMessage(toolMsg) length = %d, expected 1", len(messages))
609 } else {
610 msg := messages[0]
611 if msg.Role != "assistant" {
612 t.Errorf("message.Role = %q, expected %q", msg.Role, "assistant")
613 }
614 if msg.Content != "" {
615 t.Errorf("message.Content = %q, expected empty string", msg.Content)
616 }
617 if len(msg.ToolCalls) != 1 {
618 t.Errorf("message.ToolCalls length = %d, expected 1", len(msg.ToolCalls))
619 } else {
620 tc := msg.ToolCalls[0]
621 if tc.ID != "tool-call-1" {
622 t.Errorf("toolCall.ID = %q, expected %q", tc.ID, "tool-call-1")
623 }
624 if tc.Function.Name != "get_weather" {
625 t.Errorf("toolCall.Function.Name = %q, expected %q", tc.Function.Name, "get_weather")
626 }
627 }
628 }
629
630 // Test message with tool result
631 toolResultMsg := llm.Message{
632 Role: llm.MessageRoleUser,
633 Content: []llm.Content{
634 {
635 Type: llm.ContentTypeToolResult,
636 ToolUseID: "tool-call-1",
637 ToolResult: []llm.Content{
638 {Type: llm.ContentTypeText, Text: "Sunny"},
639 {Type: llm.ContentTypeText, Text: "72°F"},
640 },
641 },
642 },
643 }
644 messages = fromLLMMessage(toolResultMsg)
645 if len(messages) != 1 {
646 t.Errorf("fromLLMMessage(toolResultMsg) length = %d, expected 1", len(messages))
647 } else {
648 msg := messages[0]
649 if msg.Role != "tool" {
650 t.Errorf("message.Role = %q, expected %q", msg.Role, "tool")
651 }
652 expectedContent := "Sunny\n72°F"
653 if msg.Content != expectedContent {
654 t.Errorf("message.Content = %q, expected %q", msg.Content, expectedContent)
655 }
656 if msg.ToolCallID != "tool-call-1" {
657 t.Errorf("message.ToolCallID = %q, expected %q", msg.ToolCallID, "tool-call-1")
658 }
659 }
660
661 // Test message with tool result and error
662 toolResultErrorMsg := llm.Message{
663 Role: llm.MessageRoleUser,
664 Content: []llm.Content{
665 {
666 Type: llm.ContentTypeToolResult,
667 ToolUseID: "tool-call-1",
668 ToolError: true,
669 ToolResult: []llm.Content{
670 {Type: llm.ContentTypeText, Text: "API error"},
671 },
672 },
673 },
674 }
675 messages = fromLLMMessage(toolResultErrorMsg)
676 if len(messages) != 1 {
677 t.Errorf("fromLLMMessage(toolResultErrorMsg) length = %d, expected 1", len(messages))
678 } else {
679 msg := messages[0]
680 if msg.Role != "tool" {
681 t.Errorf("message.Role = %q, expected %q", msg.Role, "tool")
682 }
683 expectedContent := "error: API error"
684 if msg.Content != expectedContent {
685 t.Errorf("message.Content = %q, expected %q", msg.Content, expectedContent)
686 }
687 if msg.ToolCallID != "tool-call-1" {
688 t.Errorf("message.ToolCallID = %q, expected %q", msg.ToolCallID, "tool-call-1")
689 }
690 }
691
692 // Test message with both regular content and tool result
693 mixedMsg := llm.Message{
694 Role: llm.MessageRoleAssistant,
695 Content: []llm.Content{
696 {Type: llm.ContentTypeText, Text: "The weather is:"},
697 {
698 Type: llm.ContentTypeToolResult,
699 ToolUseID: "tool-call-1",
700 ToolResult: []llm.Content{
701 {Type: llm.ContentTypeText, Text: "Sunny"},
702 },
703 },
704 },
705 }
706 messages = fromLLMMessage(mixedMsg)
707 if len(messages) != 2 {
708 t.Errorf("fromLLMMessage(mixedMsg) length = %d, expected 2", len(messages))
709 } else {
710 // First message should be the tool result
711 toolMsg := messages[0]
712 if toolMsg.Role != "tool" {
713 t.Errorf("first message.Role = %q, expected %q", toolMsg.Role, "tool")
714 }
715 if toolMsg.Content != "Sunny" {
716 t.Errorf("first message.Content = %q, expected %q", toolMsg.Content, "Sunny")
717 }
718
719 // Second message should be the regular content
720 regularMsg := messages[1]
721 if regularMsg.Role != "assistant" {
722 t.Errorf("second message.Role = %q, expected %q", regularMsg.Role, "assistant")
723 }
724 if regularMsg.Content != "The weather is:" {
725 t.Errorf("second message.Content = %q, expected %q", regularMsg.Content, "The weather is:")
726 }
727 }
728}
729
730func TestFromLLMTool(t *testing.T) {
731 tool := &llm.Tool{
732 Name: "get_weather",
733 Description: "Get the current weather for a location",
734 InputSchema: json.RawMessage(`{"type": "object", "properties": {"location": {"type": "string"}}}`),
735 }
736 openaiTool := fromLLMTool(tool)
737 if openaiTool.Type != openai.ToolTypeFunction {
738 t.Errorf("fromLLMTool().Type = %q, expected %q", openaiTool.Type, openai.ToolTypeFunction)
739 }
740 if openaiTool.Function.Name != "get_weather" {
741 t.Errorf("fromLLMTool().Function.Name = %q, expected %q", openaiTool.Function.Name, "get_weather")
742 }
743 if openaiTool.Function.Description != "Get the current weather for a location" {
744 t.Errorf("fromLLMTool().Function.Description = %q, expected %q", openaiTool.Function.Description, "Get the current weather for a location")
745 }
746 // Note: Parameters is stored as json.RawMessage (byte slice), so we can't directly compare as string
747 // The important thing is that it's not nil and was assigned
748 if openaiTool.Function.Parameters == nil {
749 t.Errorf("fromLLMTool().Function.Parameters should not be nil")
750 }
751}
752
753func TestListModels(t *testing.T) {
754 models := ListModels()
755 if len(models) == 0 {
756 t.Errorf("ListModels() returned empty slice")
757 }
758 // Check that some known models are in the list
759 expectedModels := []string{"gpt4.1", "gpt4o", "gpt4o-mini", "o3", "o4-mini"}
760 for _, expected := range expectedModels {
761 found := false
762 for _, model := range models {
763 if model == expected {
764 found = true
765 break
766 }
767 }
768 if !found {
769 t.Errorf("ListModels() missing expected model: %s", expected)
770 }
771 }
772}
773
774func TestModelByUserName(t *testing.T) {
775 // Test finding an existing model
776 model := ModelByUserName("gpt4.1")
777 if model.UserName != "gpt4.1" {
778 t.Errorf("ModelByUserName(gpt4.1).UserName = %q, expected %q", model.UserName, "gpt4.1")
779 }
780
781 // Test finding a non-existent model
782 model = ModelByUserName("non-existent")
783 if !model.IsZero() {
784 t.Errorf("ModelByUserName(non-existent) should return zero value, got: %+v", model)
785 }
786}
787
788func TestModelIsZero(t *testing.T) {
789 // Test zero value
790 var zeroModel Model
791 if !zeroModel.IsZero() {
792 t.Errorf("Model{}.IsZero() = false, expected true")
793 }
794
795 // Test non-zero value
796 model := GPT41
797 if model.IsZero() {
798 t.Errorf("GPT41.IsZero() = true, expected false")
799 }
800}
801
802func TestToLLMUsage(t *testing.T) {
803 // Create a service instance
804 service := &Service{}
805
806 // Test usage conversion
807 openaiUsage := openai.Usage{
808 PromptTokens: 100,
809 CompletionTokens: 50,
810 }
811 usage := service.toLLMUsage(openaiUsage, nil)
812 if usage.InputTokens != 100 {
813 t.Errorf("toLLMUsage().InputTokens = %d, expected 100", usage.InputTokens)
814 }
815 if usage.OutputTokens != 50 {
816 t.Errorf("toLLMUsage().OutputTokens = %d, expected 50", usage.OutputTokens)
817 }
818 if usage.CacheReadInputTokens != 0 {
819 t.Errorf("toLLMUsage().CacheReadInputTokens = %d, expected 0", usage.CacheReadInputTokens)
820 }
821
822 // Test with prompt tokens details
823 openaiUsageWithDetails := openai.Usage{
824 PromptTokens: 100,
825 CompletionTokens: 50,
826 PromptTokensDetails: &openai.PromptTokensDetails{
827 CachedTokens: 25,
828 },
829 }
830 usage = service.toLLMUsage(openaiUsageWithDetails, nil)
831 if usage.InputTokens != 100 {
832 t.Errorf("toLLMUsage().InputTokens = %d, expected 100", usage.InputTokens)
833 }
834 if usage.CacheReadInputTokens != 25 {
835 t.Errorf("toLLMUsage().CacheReadInputTokens = %d, expected 25", usage.CacheReadInputTokens)
836 }
837}
838
839func TestToLLMResponse(t *testing.T) {
840 // Create a service instance
841 service := &Service{}
842
843 // Test response with no choices
844 emptyResponse := &openai.ChatCompletionResponse{
845 ID: "test-id",
846 Model: "gpt-4.1",
847 }
848 response := service.toLLMResponse(emptyResponse)
849 if response.ID != "test-id" {
850 t.Errorf("toLLMResponse().ID = %q, expected %q", response.ID, "test-id")
851 }
852 if response.Model != "gpt-4.1" {
853 t.Errorf("toLLMResponse().Model = %q, expected %q", response.Model, "gpt-4.1")
854 }
855 if response.Role != llm.MessageRoleAssistant {
856 t.Errorf("toLLMResponse().Role = %v, expected %v", response.Role, llm.MessageRoleAssistant)
857 }
858 if len(response.Content) != 0 {
859 t.Errorf("toLLMResponse().Content length = %d, expected 0", len(response.Content))
860 }
861
862 // Test response with a choice
863 choiceResponse := &openai.ChatCompletionResponse{
864 ID: "test-id-2",
865 Model: "gpt-4.1",
866 Choices: []openai.ChatCompletionChoice{
867 {
868 Message: openai.ChatCompletionMessage{
869 Role: "assistant",
870 Content: "Hello, world!",
871 },
872 FinishReason: openai.FinishReasonStop,
873 },
874 },
875 Usage: openai.Usage{
876 PromptTokens: 100,
877 CompletionTokens: 50,
878 },
879 }
880 response = service.toLLMResponse(choiceResponse)
881 if response.ID != "test-id-2" {
882 t.Errorf("toLLMResponse().ID = %q, expected %q", response.ID, "test-id-2")
883 }
884 if response.Model != "gpt-4.1" {
885 t.Errorf("toLLMResponse().Model = %q, expected %q", response.Model, "gpt-4.1")
886 }
887 if response.Role != llm.MessageRoleAssistant {
888 t.Errorf("toLLMResponse().Role = %v, expected %v", response.Role, llm.MessageRoleAssistant)
889 }
890 if len(response.Content) != 1 {
891 t.Errorf("toLLMResponse().Content length = %d, expected 1", len(response.Content))
892 } else {
893 content := response.Content[0]
894 if content.Type != llm.ContentTypeText {
895 t.Errorf("response.Content[0].Type = %v, expected %v", content.Type, llm.ContentTypeText)
896 }
897 if content.Text != "Hello, world!" {
898 t.Errorf("response.Content[0].Text = %q, expected %q", content.Text, "Hello, world!")
899 }
900 }
901 if response.StopReason != llm.StopReasonStopSequence {
902 t.Errorf("toLLMResponse().StopReason = %v, expected %v", response.StopReason, llm.StopReasonStopSequence)
903 }
904 if response.Usage.InputTokens != 100 {
905 t.Errorf("toLLMResponse().Usage.InputTokens = %d, expected 100", response.Usage.InputTokens)
906 }
907 if response.Usage.OutputTokens != 50 {
908 t.Errorf("toLLMResponse().Usage.OutputTokens = %d, expected 50", response.Usage.OutputTokens)
909 }
910}
911
912func TestFromLLMSystem(t *testing.T) {
913 // Test empty system content
914 messages := fromLLMSystem(nil)
915 if messages != nil {
916 t.Errorf("fromLLMSystem(nil) = %v, expected nil", messages)
917 }
918
919 // Test empty slice
920 messages = fromLLMSystem([]llm.SystemContent{})
921 if messages != nil {
922 t.Errorf("fromLLMSystem([]) = %v, expected nil", messages)
923 }
924
925 // Test single system content
926 systemContent := []llm.SystemContent{
927 {Text: "You are a helpful assistant."},
928 }
929 messages = fromLLMSystem(systemContent)
930 if len(messages) != 1 {
931 t.Errorf("fromLLMSystem(single) length = %d, expected 1", len(messages))
932 } else {
933 msg := messages[0]
934 if msg.Role != "system" {
935 t.Errorf("message.Role = %q, expected %q", msg.Role, "system")
936 }
937 if msg.Content != "You are a helpful assistant." {
938 t.Errorf("message.Content = %q, expected %q", msg.Content, "You are a helpful assistant.")
939 }
940 }
941
942 // Test multiple system content
943 multiSystemContent := []llm.SystemContent{
944 {Text: "You are a helpful assistant."},
945 {Text: "Be concise in your responses."},
946 }
947 messages = fromLLMSystem(multiSystemContent)
948 if len(messages) != 1 {
949 t.Errorf("fromLLMSystem(multiple) length = %d, expected 1", len(messages))
950 } else {
951 msg := messages[0]
952 if msg.Role != "system" {
953 t.Errorf("message.Role = %q, expected %q", msg.Role, "system")
954 }
955 expectedContent := "You are a helpful assistant.\nBe concise in your responses."
956 if msg.Content != expectedContent {
957 t.Errorf("message.Content = %q, expected %q", msg.Content, expectedContent)
958 }
959 }
960
961 // Test system content with empty text
962 emptySystemContent := []llm.SystemContent{
963 {Text: ""},
964 {Text: "You are a helpful assistant."},
965 {Text: ""},
966 }
967 messages = fromLLMSystem(emptySystemContent)
968 if len(messages) != 1 {
969 t.Errorf("fromLLMSystem(with empty) length = %d, expected 1", len(messages))
970 } else {
971 msg := messages[0]
972 if msg.Role != "system" {
973 t.Errorf("message.Role = %q, expected %q", msg.Role, "system")
974 }
975 if msg.Content != "You are a helpful assistant." {
976 t.Errorf("message.Content = %q, expected %q", msg.Content, "You are a helpful assistant.")
977 }
978 }
979
980 // Test system content with all empty text (should return nil)
981 allEmptySystemContent := []llm.SystemContent{
982 {Text: ""},
983 {Text: ""},
984 {Text: ""},
985 }
986 messages = fromLLMSystem(allEmptySystemContent)
987 if messages != nil {
988 t.Errorf("fromLLMSystem(all empty) = %v, expected nil", messages)
989 }
990}
991
992func TestFromLLMMessageEdgeCases(t *testing.T) {
993 // Test message with tool results containing empty text
994 toolResultMsg := llm.Message{
995 Role: llm.MessageRoleUser,
996 Content: []llm.Content{
997 {
998 Type: llm.ContentTypeToolResult,
999 ToolUseID: "tool-call-1",
1000 ToolResult: []llm.Content{
1001 {Type: llm.ContentTypeText, Text: ""},
1002 },
1003 },
1004 },
1005 }
1006 messages := fromLLMMessage(toolResultMsg)
1007 if len(messages) != 1 {
1008 t.Errorf("fromLLMMessage(toolResultMsg) length = %d, expected 1", len(messages))
1009 } else {
1010 msg := messages[0]
1011 if msg.Role != "tool" {
1012 t.Errorf("message.Role = %q, expected %q", msg.Role, "tool")
1013 }
1014 // Should be " " (space) when empty to avoid omitempty issues
1015 if msg.Content != " " {
1016 t.Errorf("message.Content = %q, expected %q", msg.Content, " ")
1017 }
1018 if msg.ToolCallID != "tool-call-1" {
1019 t.Errorf("message.ToolCallID = %q, expected %q", msg.ToolCallID, "tool-call-1")
1020 }
1021 }
1022
1023 // Test message with tool results containing only whitespace
1024 toolResultWhitespaceMsg := llm.Message{
1025 Role: llm.MessageRoleUser,
1026 Content: []llm.Content{
1027 {
1028 Type: llm.ContentTypeToolResult,
1029 ToolUseID: "tool-call-2",
1030 ToolResult: []llm.Content{
1031 {Type: llm.ContentTypeText, Text: " \n\t "},
1032 },
1033 },
1034 },
1035 }
1036 messages = fromLLMMessage(toolResultWhitespaceMsg)
1037 if len(messages) != 1 {
1038 t.Errorf("fromLLMMessage(toolResultWhitespaceMsg) length = %d, expected 1", len(messages))
1039 } else {
1040 msg := messages[0]
1041 if msg.Role != "tool" {
1042 t.Errorf("message.Role = %q, expected %q", msg.Role, "tool")
1043 }
1044 // Should be " " (space) when only whitespace to avoid omitempty issues
1045 if msg.Content != " " {
1046 t.Errorf("message.Content = %q, expected %q", msg.Content, " ")
1047 }
1048 if msg.ToolCallID != "tool-call-2" {
1049 t.Errorf("message.ToolCallID = %q, expected %q", msg.ToolCallID, "tool-call-2")
1050 }
1051 }
1052
1053 // Test message with tool error but empty content
1054 toolErrorEmptyMsg := llm.Message{
1055 Role: llm.MessageRoleUser,
1056 Content: []llm.Content{
1057 {
1058 Type: llm.ContentTypeToolResult,
1059 ToolUseID: "tool-call-3",
1060 ToolError: true,
1061 ToolResult: []llm.Content{
1062 {Type: llm.ContentTypeText, Text: ""},
1063 },
1064 },
1065 },
1066 }
1067 messages = fromLLMMessage(toolErrorEmptyMsg)
1068 if len(messages) != 1 {
1069 t.Errorf("fromLLMMessage(toolErrorEmptyMsg) length = %d, expected 1", len(messages))
1070 } else {
1071 msg := messages[0]
1072 if msg.Role != "tool" {
1073 t.Errorf("message.Role = %q, expected %q", msg.Role, "tool")
1074 }
1075 expectedContent := "error: tool execution failed"
1076 if msg.Content != expectedContent {
1077 t.Errorf("message.Content = %q, expected %q", msg.Content, expectedContent)
1078 }
1079 if msg.ToolCallID != "tool-call-3" {
1080 t.Errorf("message.ToolCallID = %q, expected %q", msg.ToolCallID, "tool-call-3")
1081 }
1082 }
1083
1084 // Test message with tool error and content
1085 toolErrorWithContentMsg := llm.Message{
1086 Role: llm.MessageRoleUser,
1087 Content: []llm.Content{
1088 {
1089 Type: llm.ContentTypeToolResult,
1090 ToolUseID: "tool-call-4",
1091 ToolError: true,
1092 ToolResult: []llm.Content{
1093 {Type: llm.ContentTypeText, Text: "something went wrong"},
1094 },
1095 },
1096 },
1097 }
1098 messages = fromLLMMessage(toolErrorWithContentMsg)
1099 if len(messages) != 1 {
1100 t.Errorf("fromLLMMessage(toolErrorWithContentMsg) length = %d, expected 1", len(messages))
1101 } else {
1102 msg := messages[0]
1103 if msg.Role != "tool" {
1104 t.Errorf("message.Role = %q, expected %q", msg.Role, "tool")
1105 }
1106 expectedContent := "error: something went wrong"
1107 if msg.Content != expectedContent {
1108 t.Errorf("message.Content = %q, expected %q", msg.Content, expectedContent)
1109 }
1110 if msg.ToolCallID != "tool-call-4" {
1111 t.Errorf("message.ToolCallID = %q, expected %q", msg.ToolCallID, "tool-call-4")
1112 }
1113 }
1114
1115 // Test message with mixed content (regular text + tool results)
1116 mixedContentMsg := llm.Message{
1117 Role: llm.MessageRoleAssistant,
1118 Content: []llm.Content{
1119 {Type: llm.ContentTypeText, Text: "Here's the result:"},
1120 {
1121 Type: llm.ContentTypeToolResult,
1122 ToolUseID: "tool-call-5",
1123 ToolResult: []llm.Content{
1124 {Type: llm.ContentTypeText, Text: "The weather is sunny"},
1125 },
1126 },
1127 {Type: llm.ContentTypeText, Text: "Have a nice day!"},
1128 },
1129 }
1130 messages = fromLLMMessage(mixedContentMsg)
1131 // Should produce 2 messages: one tool result message and one regular message
1132 if len(messages) != 2 {
1133 t.Errorf("fromLLMMessage(mixedContentMsg) length = %d, expected 2", len(messages))
1134 } else {
1135 // First message should be the tool result
1136 toolMsg := messages[0]
1137 if toolMsg.Role != "tool" {
1138 t.Errorf("first message.Role = %q, expected %q", toolMsg.Role, "tool")
1139 }
1140 if toolMsg.Content != "The weather is sunny" {
1141 t.Errorf("first message.Content = %q, expected %q", toolMsg.Content, "The weather is sunny")
1142 }
1143 if toolMsg.ToolCallID != "tool-call-5" {
1144 t.Errorf("first message.ToolCallID = %q, expected %q", toolMsg.ToolCallID, "tool-call-5")
1145 }
1146
1147 // Second message should be the regular content
1148 regularMsg := messages[1]
1149 if regularMsg.Role != "assistant" {
1150 t.Errorf("second message.Role = %q, expected %q", regularMsg.Role, "assistant")
1151 }
1152 // Should combine both text contents with newline
1153 expectedContent := "Here's the result:\nHave a nice day!"
1154 if regularMsg.Content != expectedContent {
1155 t.Errorf("second message.Content = %q, expected %q", regularMsg.Content, expectedContent)
1156 }
1157 }
1158}
1159
1160func TestTokenContextWindowAdditionalCases(t *testing.T) {
1161 tests := []struct {
1162 name string
1163 model Model
1164 expected int
1165 }{
1166 {
1167 name: "GPT-4.1 Mini model",
1168 model: GPT41Mini,
1169 expected: 200000,
1170 },
1171 {
1172 name: "GPT-4.1 Nano model",
1173 model: GPT41Nano,
1174 expected: 200000,
1175 },
1176 {
1177 name: "Qwen3 Coder Fireworks model",
1178 model: Qwen3CoderFireworks,
1179 expected: 256000,
1180 },
1181 {
1182 name: "Qwen3 Coder Cerebras model",
1183 model: Qwen3CoderCerebras,
1184 expected: 128000, // The model name "qwen-3-coder-480b" is not in the special cases, so it defaults to 128k
1185 },
1186 {
1187 name: "GLM model",
1188 model: GLM,
1189 expected: 128000,
1190 },
1191 {
1192 name: "Qwen model",
1193 model: Qwen,
1194 expected: 256000,
1195 },
1196 {
1197 name: "GPT-OSS 20B model",
1198 model: GPTOSS20B,
1199 expected: 128000,
1200 },
1201 {
1202 name: "GPT-OSS 120B model",
1203 model: GPTOSS120B,
1204 expected: 128000,
1205 },
1206 {
1207 name: "GPT-5 model",
1208 model: GPT5,
1209 expected: 256000,
1210 },
1211 {
1212 name: "GPT-5 Mini model",
1213 model: GPT5Mini,
1214 expected: 256000,
1215 },
1216 {
1217 name: "GPT-5 Nano model",
1218 model: GPT5Nano,
1219 expected: 256000,
1220 },
1221 {
1222 name: "Unknown model defaults to 128k",
1223 model: Model{ModelName: "unknown-model-name"},
1224 expected: 128000,
1225 },
1226 }
1227
1228 for _, tt := range tests {
1229 t.Run(tt.name, func(t *testing.T) {
1230 service := &Service{Model: tt.model}
1231 result := service.TokenContextWindow()
1232 if result != tt.expected {
1233 t.Errorf("TokenContextWindow() for model %s = %d, expected %d", tt.model.ModelName, result, tt.expected)
1234 }
1235 })
1236 }
1237}
1238
1239func TestServiceDo(t *testing.T) {
1240 // Create a mock OpenAI server
1241 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1242 if r.URL.Path != "/v1/chat/completions" {
1243 t.Errorf("Expected path /v1/chat/completions, got %s", r.URL.Path)
1244 }
1245 if r.Header.Get("Authorization") != "Bearer test-api-key" {
1246 t.Errorf("Expected Authorization header, got %s", r.Header.Get("Authorization"))
1247 }
1248
1249 // Send a mock response
1250 response := openai.ChatCompletionResponse{
1251 ID: "chatcmpl-test123",
1252 Object: "chat.completion",
1253 Created: time.Now().Unix(),
1254 Model: "gpt-4.1-2025-04-14",
1255 Choices: []openai.ChatCompletionChoice{
1256 {
1257 Index: 0,
1258 Message: openai.ChatCompletionMessage{
1259 Role: "assistant",
1260 Content: "Hello! How can I help you today?",
1261 },
1262 FinishReason: "stop",
1263 },
1264 },
1265 Usage: openai.Usage{
1266 PromptTokens: 10,
1267 CompletionTokens: 20,
1268 TotalTokens: 30,
1269 },
1270 }
1271
1272 w.Header().Set("Content-Type", "application/json")
1273 json.NewEncoder(w).Encode(response)
1274 }))
1275 defer server.Close()
1276
1277 // Create a service with the mock server
1278 ctx := context.Background()
1279 svc := &Service{
1280 APIKey: "test-api-key",
1281 Model: GPT41,
1282 ModelURL: server.URL + "/v1",
1283 }
1284
1285 // Create a test request
1286 req := &llm.Request{
1287 Messages: []llm.Message{
1288 {
1289 Role: llm.MessageRoleUser,
1290 Content: []llm.Content{
1291 {Type: llm.ContentTypeText, Text: "Hello!"},
1292 },
1293 },
1294 },
1295 }
1296
1297 // Call the Do method
1298 resp, err := svc.Do(ctx, req)
1299 if err != nil {
1300 t.Fatalf("Do() error = %v", err)
1301 }
1302
1303 // Verify the response
1304 if resp == nil {
1305 t.Fatal("Do() returned nil response")
1306 }
1307 if resp.Role != llm.MessageRoleAssistant {
1308 t.Errorf("resp.Role = %v, expected %v", resp.Role, llm.MessageRoleAssistant)
1309 }
1310 if len(resp.Content) != 1 {
1311 t.Errorf("resp.Content length = %d, expected 1", len(resp.Content))
1312 } else {
1313 content := resp.Content[0]
1314 if content.Type != llm.ContentTypeText {
1315 t.Errorf("content.Type = %v, expected %v", content.Type, llm.ContentTypeText)
1316 }
1317 if content.Text != "Hello! How can I help you today?" {
1318 t.Errorf("content.Text = %q, expected %q", content.Text, "Hello! How can I help you today?")
1319 }
1320 }
1321 if resp.Usage.InputTokens != 10 {
1322 t.Errorf("resp.Usage.InputTokens = %d, expected 10", resp.Usage.InputTokens)
1323 }
1324 if resp.Usage.OutputTokens != 20 {
1325 t.Errorf("resp.Usage.OutputTokens = %d, expected 20", resp.Usage.OutputTokens)
1326 }
1327}