diff --git a/internal/provider/elevenlabs.go b/internal/provider/elevenlabs.go new file mode 100644 index 0000000..a36a173 --- /dev/null +++ b/internal/provider/elevenlabs.go @@ -0,0 +1,41 @@ +package provider + +// ElevenLabsProvider implements Provider for ElevenLabs services (transcription only) +type ElevenLabsProvider struct{} + +func (p *ElevenLabsProvider) Name() string { + return "elevenlabs" +} + +func (p *ElevenLabsProvider) RequiresAPIKey() bool { + return true +} + +func (p *ElevenLabsProvider) ValidateAPIKey(key string) bool { + // ElevenLabs API keys don't have a consistent prefix, just check non-empty + return len(key) > 0 +} + +func (p *ElevenLabsProvider) SupportsTranscription() bool { + return true +} + +func (p *ElevenLabsProvider) SupportsLLM() bool { + return false +} + +func (p *ElevenLabsProvider) DefaultTranscriptionModel() string { + return "scribe_v1" +} + +func (p *ElevenLabsProvider) DefaultLLMModel() string { + return "" +} + +func (p *ElevenLabsProvider) TranscriptionModels() []string { + return []string{"scribe_v1", "scribe_v2"} +} + +func (p *ElevenLabsProvider) LLMModels() []string { + return nil +} diff --git a/internal/provider/groq.go b/internal/provider/groq.go new file mode 100644 index 0000000..77cc4c6 --- /dev/null +++ b/internal/provider/groq.go @@ -0,0 +1,42 @@ +package provider + +import "strings" + +// GroqProvider implements Provider for Groq services +type GroqProvider struct{} + +func (p *GroqProvider) Name() string { + return "groq" +} + +func (p *GroqProvider) RequiresAPIKey() bool { + return true +} + +func (p *GroqProvider) ValidateAPIKey(key string) bool { + return strings.HasPrefix(key, "gsk_") +} + +func (p *GroqProvider) SupportsTranscription() bool { + return true +} + +func (p *GroqProvider) SupportsLLM() bool { + return true +} + +func (p *GroqProvider) DefaultTranscriptionModel() string { + return "whisper-large-v3-turbo" +} + +func (p *GroqProvider) DefaultLLMModel() string { + return "llama-3.3-70b-versatile" +} + +func (p *GroqProvider) TranscriptionModels() []string { + return []string{"whisper-large-v3", "whisper-large-v3-turbo"} +} + +func (p *GroqProvider) LLMModels() []string { + return []string{"llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"} +} diff --git a/internal/provider/mistral.go b/internal/provider/mistral.go new file mode 100644 index 0000000..c080701 --- /dev/null +++ b/internal/provider/mistral.go @@ -0,0 +1,41 @@ +package provider + +// MistralProvider implements Provider for Mistral services (transcription only) +type MistralProvider struct{} + +func (p *MistralProvider) Name() string { + return "mistral" +} + +func (p *MistralProvider) RequiresAPIKey() bool { + return true +} + +func (p *MistralProvider) ValidateAPIKey(key string) bool { + // Mistral API keys don't have a consistent prefix, just check non-empty + return len(key) > 0 +} + +func (p *MistralProvider) SupportsTranscription() bool { + return true +} + +func (p *MistralProvider) SupportsLLM() bool { + return false +} + +func (p *MistralProvider) DefaultTranscriptionModel() string { + return "voxtral-mini-latest" +} + +func (p *MistralProvider) DefaultLLMModel() string { + return "" +} + +func (p *MistralProvider) TranscriptionModels() []string { + return []string{"voxtral-mini-latest", "voxtral-mini-2507"} +} + +func (p *MistralProvider) LLMModels() []string { + return nil +} diff --git a/internal/provider/openai.go b/internal/provider/openai.go new file mode 100644 index 0000000..318ba30 --- /dev/null +++ b/internal/provider/openai.go @@ -0,0 +1,42 @@ +package provider + +import "strings" + +// OpenAIProvider implements Provider for OpenAI services +type OpenAIProvider struct{} + +func (p *OpenAIProvider) Name() string { + return "openai" +} + +func (p *OpenAIProvider) RequiresAPIKey() bool { + return true +} + +func (p *OpenAIProvider) ValidateAPIKey(key string) bool { + return strings.HasPrefix(key, "sk-") +} + +func (p *OpenAIProvider) SupportsTranscription() bool { + return true +} + +func (p *OpenAIProvider) SupportsLLM() bool { + return true +} + +func (p *OpenAIProvider) DefaultTranscriptionModel() string { + return "whisper-1" +} + +func (p *OpenAIProvider) DefaultLLMModel() string { + return "gpt-4o-mini" +} + +func (p *OpenAIProvider) TranscriptionModels() []string { + return []string{"whisper-1"} +} + +func (p *OpenAIProvider) LLMModels() []string { + return []string{"gpt-4o-mini", "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"} +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..8c5d243 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,69 @@ +package provider + +// Provider defines the interface for a transcription/LLM service provider +type Provider interface { + Name() string + RequiresAPIKey() bool + ValidateAPIKey(key string) bool + SupportsTranscription() bool + SupportsLLM() bool + DefaultTranscriptionModel() string + DefaultLLMModel() string + TranscriptionModels() []string + LLMModels() []string +} + +// ProviderConfig holds configuration for a single provider +type ProviderConfig struct { + APIKey string `toml:"api_key"` +} + +var registry = make(map[string]Provider) + +func init() { + Register(&OpenAIProvider{}) + Register(&GroqProvider{}) + Register(&MistralProvider{}) + Register(&ElevenLabsProvider{}) +} + +// Register adds a provider to the registry +func Register(p Provider) { + registry[p.Name()] = p +} + +// GetProvider returns a provider by name, or nil if not found +func GetProvider(name string) Provider { + return registry[name] +} + +// ListProviders returns all registered provider names +func ListProviders() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + return names +} + +// ListProvidersWithTranscription returns providers that support transcription +func ListProvidersWithTranscription() []string { + var names []string + for name, p := range registry { + if p.SupportsTranscription() { + names = append(names, name) + } + } + return names +} + +// ListProvidersWithLLM returns providers that support LLM +func ListProvidersWithLLM() []string { + var names []string + for name, p := range registry { + if p.SupportsLLM() { + names = append(names, name) + } + } + return names +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 0000000..8a69af7 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,139 @@ +package provider + +import ( + "slices" + "testing" +) + +func TestProviderInterface(t *testing.T) { + providers := []struct { + name string + hasTranscription bool + hasLLM bool + defaultTransModel string + defaultLLMModel string + }{ + {"openai", true, true, "whisper-1", "gpt-4o-mini"}, + {"groq", true, true, "whisper-large-v3-turbo", "llama-3.3-70b-versatile"}, + {"mistral", true, false, "voxtral-mini-latest", ""}, + {"elevenlabs", true, false, "scribe_v1", ""}, + } + + for _, tc := range providers { + t.Run(tc.name, func(t *testing.T) { + p := GetProvider(tc.name) + if p == nil { + t.Fatalf("GetProvider(%q) returned nil", tc.name) + } + + if p.Name() != tc.name { + t.Errorf("Name() = %q, want %q", p.Name(), tc.name) + } + + if p.SupportsTranscription() != tc.hasTranscription { + t.Errorf("SupportsTranscription() = %v, want %v", p.SupportsTranscription(), tc.hasTranscription) + } + + if p.SupportsLLM() != tc.hasLLM { + t.Errorf("SupportsLLM() = %v, want %v", p.SupportsLLM(), tc.hasLLM) + } + + if p.DefaultTranscriptionModel() != tc.defaultTransModel { + t.Errorf("DefaultTranscriptionModel() = %q, want %q", p.DefaultTranscriptionModel(), tc.defaultTransModel) + } + + if p.DefaultLLMModel() != tc.defaultLLMModel { + t.Errorf("DefaultLLMModel() = %q, want %q", p.DefaultLLMModel(), tc.defaultLLMModel) + } + + if !p.RequiresAPIKey() { + t.Error("RequiresAPIKey() should be true for all providers") + } + + if tc.hasTranscription && len(p.TranscriptionModels()) == 0 { + t.Error("TranscriptionModels() should not be empty for transcription provider") + } + + if tc.hasLLM && len(p.LLMModels()) == 0 { + t.Error("LLMModels() should not be empty for LLM provider") + } + }) + } +} + +func TestGetProviderNotFound(t *testing.T) { + p := GetProvider("nonexistent") + if p != nil { + t.Errorf("GetProvider(nonexistent) should return nil, got %v", p) + } +} + +func TestListProviders(t *testing.T) { + providers := ListProviders() + expected := []string{"openai", "groq", "mistral", "elevenlabs"} + + for _, name := range expected { + if !slices.Contains(providers, name) { + t.Errorf("ListProviders() missing %q", name) + } + } +} + +func TestListProvidersWithTranscription(t *testing.T) { + providers := ListProvidersWithTranscription() + // All providers support transcription + expected := []string{"openai", "groq", "mistral", "elevenlabs"} + + for _, name := range expected { + if !slices.Contains(providers, name) { + t.Errorf("ListProvidersWithTranscription() missing %q", name) + } + } +} + +func TestListProvidersWithLLM(t *testing.T) { + providers := ListProvidersWithLLM() + expected := []string{"openai", "groq"} + + for _, name := range expected { + if !slices.Contains(providers, name) { + t.Errorf("ListProvidersWithLLM() missing %q", name) + } + } + + // Mistral and ElevenLabs should NOT be in the list + notExpected := []string{"mistral", "elevenlabs"} + for _, name := range notExpected { + if slices.Contains(providers, name) { + t.Errorf("ListProvidersWithLLM() should not include %q", name) + } + } +} + +func TestValidateAPIKey(t *testing.T) { + tests := []struct { + provider string + key string + valid bool + }{ + {"openai", "sk-abc123", true}, + {"openai", "invalid", false}, + {"openai", "", false}, + {"groq", "gsk_abc123", true}, + {"groq", "invalid", false}, + {"groq", "", false}, + {"mistral", "any-non-empty", true}, + {"mistral", "", false}, + {"elevenlabs", "any-non-empty", true}, + {"elevenlabs", "", false}, + } + + for _, tc := range tests { + t.Run(tc.provider+"_"+tc.key, func(t *testing.T) { + p := GetProvider(tc.provider) + if p.ValidateAPIKey(tc.key) != tc.valid { + t.Errorf("ValidateAPIKey(%q) = %v, want %v", tc.key, !tc.valid, tc.valid) + } + }) + } +} diff --git a/progress.txt b/progress.txt new file mode 100644 index 0000000..c4100d7 --- /dev/null +++ b/progress.txt @@ -0,0 +1,17 @@ +# Ralph Progress Log +Started: Sat Jan 31 08:30:51 PM CET 2026 +--- + +## Task 1: Create Provider interface and registry - COMPLETE + +Created internal/provider package with: +- Provider interface with all required methods +- ProviderConfig struct for API key storage +- 4 provider implementations: OpenAI, Groq, Mistral, ElevenLabs +- Registry with GetProvider(), ListProviders(), ListProvidersWithLLM(), ListProvidersWithTranscription() +- Comprehensive tests (all passing) + +Key decisions: +- OpenAI and Groq support both transcription + LLM +- Mistral and ElevenLabs are transcription-only +- ValidateAPIKey checks prefix for OpenAI (sk-) and Groq (gsk_), accepts any non-empty for others diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index aa3da2b..9cd89b5 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -22,7 +22,7 @@ "Capability methods return correct values", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Refactor config to unified provider structure",