Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/gonzo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func init() {
rootCmd.Flags().IntP("log-buffer", "b", 1000, "Maximum log buffer size")
rootCmd.Flags().BoolP("test-mode", "t", false, "Run in test mode (works without TTY)")
rootCmd.Flags().BoolP("version", "v", false, "Print version information")
rootCmd.Flags().String("ai-provider", "", "AI provider to use: 'openai' (default, requires OPENAI_API_KEY), 'claude-code' (requires claude CLI)")
rootCmd.Flags().String("ai-provider", "", "AI provider to use: 'openai' (default, requires OPENAI_API_KEY), 'claude-code' (requires claude CLI), 'litellm' (requires LITELLM_API_KEY)")
rootCmd.Flags().String("ai-model", "", "AI model to use for log analysis (auto-selects best available if not specified)")
rootCmd.Flags().StringSliceP("file", "f", []string{}, "Files or file globs to read logs from (can specify multiple)")
rootCmd.Flags().Bool("follow", false, "Follow log files like 'tail -f' (watch for new lines in real-time)")
Expand Down
11 changes: 10 additions & 1 deletion internal/ai/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const (
// ProviderClaudeCode uses the Claude Code CLI
ProviderClaudeCode ProviderType = "claude-code"

// ProviderLiteLLM uses a LiteLLM proxy (OpenAI-wire compatible gateway
// to 100+ providers). Requires LITELLM_API_KEY.
ProviderLiteLLM ProviderType = "litellm"

// ProviderAuto auto-detects the provider based on environment.
// This is the default and maintains backwards compatibility:
// - If OPENAI_API_KEY is set, uses OpenAI client
Expand Down Expand Up @@ -43,6 +47,11 @@ func NewClient(provider ProviderType, model string) (Client, error) {
// so TUI can display the validation error
return NewClaudeCodeClient(model), nil

case ProviderLiteLLM:
// Explicitly requested LiteLLM - returns client even if not validated
// so TUI can display the validation error
return NewLiteLLMClient(model), nil

case ProviderAuto:
// Auto-detect: maintain backwards compatibility
// Only try OpenAI (the previous default behavior)
Expand All @@ -60,5 +69,5 @@ func NewClient(provider ProviderType, model string) (Client, error) {

// ValidProviders returns the list of valid provider type strings
func ValidProviders() []string {
return []string{string(ProviderOpenAI), string(ProviderClaudeCode)}
return []string{string(ProviderOpenAI), string(ProviderClaudeCode), string(ProviderLiteLLM)}
}
54 changes: 54 additions & 0 deletions internal/ai/litellm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package ai

import (
"net/http"
"os"
"time"
)

// LiteLLM proxy defaults. A LiteLLM proxy speaks the OpenAI wire format, so the
// LiteLLM provider reuses the tested OpenAIClient and only differs in its
// defaults and environment variables. Point it at your proxy to reach 100+
// providers (OpenAI, Anthropic, Gemini, Bedrock, Vertex AI, Azure, ...) through
// a single endpoint with unified auth, routing and observability.
const defaultLiteLLMBaseURL = "http://localhost:4000/v1"

// NewLiteLLMClient creates a client for a LiteLLM proxy.
//
// Configuration (mirrors the OpenAI provider, with LiteLLM-specific env vars):
// - LITELLM_API_KEY: the proxy master/virtual key (required)
// - LITELLM_API_BASE: the proxy base URL (default http://localhost:4000/v1)
//
// LiteLLM is OpenAI-wire compatible, so this reuses OpenAIClient (and therefore
// its chat, model-discovery, validation and retry behavior) verbatim.
func NewLiteLLMClient(model string) *OpenAIClient {
apiKey := os.Getenv("LITELLM_API_KEY")
if apiKey == "" {
return &OpenAIClient{
Validated: false,
ValidationErr: "LITELLM_API_KEY environment variable not set",
ServiceName: "LiteLLM",
}
}

baseURL := os.Getenv("LITELLM_API_BASE")
if baseURL == "" {
baseURL = defaultLiteLLMBaseURL
}

client := &OpenAIClient{
APIKey: apiKey,
BaseURL: baseURL,
Model: model,
ServiceName: "LiteLLM",
AutoSelectModel: model == "",
HTTPClient: &http.Client{
Timeout: 60 * time.Second,
},
}

// Validate configuration and get available models (via the proxy's /models).
client.ValidateConfiguration()

return client
}
77 changes: 77 additions & 0 deletions internal/ai/litellm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package ai

import (
"slices"
"testing"
)

func TestNewLiteLLMClient_NoAPIKeyReturnsUnvalidated(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "")

client := NewLiteLLMClient("")

if client.Validated {
t.Fatal("expected unvalidated client when LITELLM_API_KEY is unset")
}
if client.ServiceName != "LiteLLM" {
t.Fatalf("expected ServiceName %q, got %q", "LiteLLM", client.ServiceName)
}
if client.ValidationErr == "" {
t.Fatal("expected a validation error message when LITELLM_API_KEY is unset")
}
}

func TestNewLiteLLMClient_DefaultBaseURL(t *testing.T) {
// Set a key so the constructor proceeds past the no-key guard. Validation
// makes a network call and may fail (no proxy running), but BaseURL/defaults
// are assigned before that.
t.Setenv("LITELLM_API_KEY", "sk-test")
t.Setenv("LITELLM_API_BASE", "")

client := NewLiteLLMClient("some-model")

if client.BaseURL != defaultLiteLLMBaseURL {
t.Fatalf("expected default BaseURL %q, got %q", defaultLiteLLMBaseURL, client.BaseURL)
}
if client.APIKey != "sk-test" {
t.Fatalf("expected APIKey to be forwarded, got %q", client.APIKey)
}
if client.ServiceName != "LiteLLM" {
t.Fatalf("expected ServiceName %q, got %q", "LiteLLM", client.ServiceName)
}
if client.Model != "some-model" {
t.Fatalf("expected Model %q, got %q", "some-model", client.Model)
}
}

func TestNewLiteLLMClient_CustomBaseURL(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-test")
t.Setenv("LITELLM_API_BASE", "http://127.0.0.1:4000/v1")

client := NewLiteLLMClient("m")

if client.BaseURL != "http://127.0.0.1:4000/v1" {
t.Fatalf("expected custom BaseURL to be honored, got %q", client.BaseURL)
}
}

func TestNewClient_LiteLLMProvider(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "")

client, err := NewClient(ProviderLiteLLM, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client == nil {
t.Fatal("expected a non-nil client for explicit litellm provider")
}
if got := client.GetValidationStatus().ServiceName; got != "LiteLLM" {
t.Fatalf("expected ServiceName %q, got %q", "LiteLLM", got)
}
}

func TestValidProviders_IncludesLiteLLM(t *testing.T) {
if !slices.Contains(ValidProviders(), "litellm") {
t.Fatalf("expected ValidProviders to include %q, got %v", "litellm", ValidProviders())
}
}