diff --git a/cmd/gonzo/main.go b/cmd/gonzo/main.go index c924626..f264910 100644 --- a/cmd/gonzo/main.go +++ b/cmd/gonzo/main.go @@ -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)") diff --git a/internal/ai/factory.go b/internal/ai/factory.go index a6cd580..c68e311 100644 --- a/internal/ai/factory.go +++ b/internal/ai/factory.go @@ -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 @@ -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) @@ -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)} } diff --git a/internal/ai/litellm.go b/internal/ai/litellm.go new file mode 100644 index 0000000..91edf18 --- /dev/null +++ b/internal/ai/litellm.go @@ -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 +} diff --git a/internal/ai/litellm_test.go b/internal/ai/litellm_test.go new file mode 100644 index 0000000..fd2f6f0 --- /dev/null +++ b/internal/ai/litellm_test.go @@ -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()) + } +}