package llm import ( "context" "encoding/json" "net/http" "os" "testing" ) func TestNewClientMissingAPIKey(t *testing.T) { // Ensure OPENAI_API_KEY is not set os.Unsetenv("OPENAI_API_KEY") _, err := NewClient() if err == nil { t.Fatal("NewClient should return error when OPENAI_API_KEY is not set") } } func TestNewClientWithAPIKey(t *testing.T) { os.Setenv("OPENAI_API_KEY", "test-key-123") defer os.Unsetenv("OPENAI_API_KEY") os.Unsetenv("OPENAI_MODEL") c, err := NewClient() if err != nil { t.Fatalf("NewClient with valid key should not error: %v", err) } if c == nil { t.Fatal("NewClient should not return nil client") } if c.apiKey != "test-key-123" { t.Errorf("Client.apiKey = %q, want 'test-key-123'", c.apiKey) } if c.model != "gpt-4o-mini" { t.Errorf("Client.model = %q, want 'gpt-4o-mini' (default)", c.model) } if c.httpClient == nil { t.Error("Client.httpClient should not be nil") } } func TestNewClientCustomModel(t *testing.T) { os.Setenv("OPENAI_API_KEY", "test-key") os.Setenv("OPENAI_MODEL", "gpt-4o") defer func() { os.Unsetenv("OPENAI_API_KEY") os.Unsetenv("OPENAI_MODEL") }() c, err := NewClient() if err != nil { t.Fatalf("NewClient should not error: %v", err) } if c.model != "gpt-4o" { t.Errorf("Client.model = %q, want 'gpt-4o'", c.model) } } func TestClientNewReq(t *testing.T) { c := &Client{ model: "gpt-4o-mini", } t.Run("non-gpt-5 model uses MaxTokens", func(t *testing.T) { req := c.newReq(16000, 0.1, false) if req.MaxTokens != 16000 { t.Errorf("MaxTokens = %d, want 16000", req.MaxTokens) } if req.MaxCompletionTokens != 0 { t.Errorf("MaxCompletionTokens = %d, want 0", req.MaxCompletionTokens) } }) t.Run("gpt-5 model uses MaxCompletionTokens", func(t *testing.T) { c5 := &Client{model: "gpt-5-turbo"} req := c5.newReq(16000, 0.1, false) if req.MaxCompletionTokens != 16000 { t.Errorf("MaxCompletionTokens = %d, want 16000", req.MaxCompletionTokens) } if req.MaxTokens != 0 { t.Errorf("MaxTokens = %d, want 0", req.MaxTokens) } }) t.Run("JSON response format", func(t *testing.T) { req := c.newReq(1000, 0.5, true) if req.ResponseFormat == nil { t.Error("ResponseFormat should be set when wantJSON is true") } else if req.ResponseFormat.Type != "json_object" { t.Errorf("ResponseFormat.Type = %q, want 'json_object'", req.ResponseFormat.Type) } }) t.Run("no JSON response format", func(t *testing.T) { req := c.newReq(1000, 0.5, false) if req.ResponseFormat != nil { t.Errorf("ResponseFormat should be nil when wantJSON is false, got %+v", req.ResponseFormat) } }) t.Run("temperature is set", func(t *testing.T) { req := c.newReq(1000, 0.3, false) if req.Temperature != 0.3 { t.Errorf("Temperature = %f, want 0.3", req.Temperature) } }) t.Run("model is set", func(t *testing.T) { req := c.newReq(1000, 0.0, false) if req.Model != "gpt-4o-mini" { t.Errorf("Model = %q, want 'gpt-4o-mini'", req.Model) } }) } func TestChatRequestMarshal(t *testing.T) { req := chatRequest{ Model: "gpt-4o-mini", MaxTokens: 16000, Temperature: 0.1, Messages: []chatMsg{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hello"}, }, } data, err := json.Marshal(req) if err != nil { t.Fatalf("json.Marshal(chatRequest) error: %v", err) } var decoded chatRequest if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("json.Unmarshal error: %v", err) } if decoded.Model != req.Model { t.Errorf("decoded.Model = %q, want %q", decoded.Model, req.Model) } if len(decoded.Messages) != 2 { t.Errorf("decoded.Messages length = %d, want 2", len(decoded.Messages)) } if decoded.Messages[0].Role != "system" { t.Errorf("decoded.Messages[0].Role = %q, want 'system'", decoded.Messages[0].Role) } if decoded.Messages[1].Role != "user" { t.Errorf("decoded.Messages[1].Role = %q, want 'user'", decoded.Messages[1].Role) } } func TestChatResponseError(t *testing.T) { resp := &chatResponse{ Error: &struct { Message string `json:"message"` Type string `json:"type"` }{ Message: "rate limit exceeded", Type: "rate_limit_error", }, } if resp.Error == nil { t.Fatal("response should have error") } if resp.Error.Message != "rate limit exceeded" { t.Errorf("Error.Message = %q, want 'rate limit exceeded'", resp.Error.Message) } if resp.Error.Type != "rate_limit_error" { t.Errorf("Error.Type = %q, want 'rate_limit_error'", resp.Error.Type) } } func TestTokenUsage(t *testing.T) { usage := &TokenUsage{ InputTokens: 100, OutputTokens: 50, TotalTokens: 150, } if usage.InputTokens != 100 { t.Errorf("InputTokens = %d, want 100", usage.InputTokens) } if usage.OutputTokens != 50 { t.Errorf("OutputTokens = %d, want 50", usage.OutputTokens) } if usage.TotalTokens != 150 { t.Errorf("TotalTokens = %d, want 150", usage.TotalTokens) } } func TestChatResponseUsage(t *testing.T) { resp := &chatResponse{ Choices: []struct { Message chatMsg `json:"message"` }{ {Message: chatMsg{Role: "assistant", Content: "Hello"}}, }, Usage: &struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` }{ PromptTokens: 200, CompletionTokens: 100, TotalTokens: 300, }, } if resp.Usage == nil { t.Fatal("response usage should not be nil") } if resp.Usage.PromptTokens != 200 { t.Errorf("Usage.PromptTokens = %d, want 200", resp.Usage.PromptTokens) } } func TestBuildFormatMessage(t *testing.T) { msg := BuildFormatMessage("some markdown content", "extra instructions") if msg == "" { t.Error("BuildFormatMessage should not return empty string") } } func TestHasOpenAIKey(t *testing.T) { os.Unsetenv("OPENAI_API_KEY") if HasOpenAIKey() { t.Error("HasOpenAIKey should return false when key is not set") } os.Setenv("OPENAI_API_KEY", "test-key") defer os.Unsetenv("OPENAI_API_KEY") if !HasOpenAIKey() { t.Error("HasOpenAIKey should return true when key is set") } } func TestClientDoChatRawRetryLoopStructure(t *testing.T) { // Verify the retry loop is correctly structured by checking that // doChatRaw handles context cancellation gracefully. c := &Client{ httpClient: &http.Client{}, apiKey: "test-key", model: "gpt-4o-mini", } ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately req := c.newReq(1000, 0.1, false) req.Messages = []chatMsg{ {Role: "system", Content: "test"}, {Role: "user", Content: "test"}, } // This will fail (no actual HTTP server), but it verifies the // struct is well-formed and the method is callable. _, err := c.doChatRaw(ctx, req) if err == nil { // With a cancelled context and no server, we expect an error t.Error("expected error with cancelled context") } }