diff --git a/internal/transcriber/adapter_openai.go b/internal/transcriber/adapter_openai.go index 12887fd..dfb99c5 100644 --- a/internal/transcriber/adapter_openai.go +++ b/internal/transcriber/adapter_openai.go @@ -8,21 +8,54 @@ import ( "strings" "time" + "github.com/leonardotrapani/hyprvoice/internal/language" + "github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/sashabaranov/go-openai" ) -// OpenAIAdapter implements BatchAdapter for OpenAI Whisper API +// OpenAIAdapter implements BatchAdapter for any OpenAI-compatible API +// Works with OpenAI, Groq, Mistral, and any other OpenAI-compatible endpoint type OpenAIAdapter struct { - client *openai.Client - config Config + client *openai.Client + model string + language string + keywords []string + providerName string } -func NewOpenAIAdapter(config Config) *OpenAIAdapter { - client := openai.NewClient(config.APIKey) - return &OpenAIAdapter{ - client: client, - config: config, +// NewOpenAIAdapter creates an adapter for OpenAI-compatible transcription APIs +// endpoint: the BaseURL for the API (e.g., "https://api.openai.com", "https://api.groq.com/openai") +// apiKey: the API key for authentication +// model: model ID to use +// lang: canonical language code (will be converted to provider format) +// keywords: optional spelling hints +// providerName: used for logging and language format conversion +func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string, providerName string) *OpenAIAdapter { + var client *openai.Client + + if endpoint != nil && endpoint.BaseURL != "" { + // use custom endpoint + clientConfig := openai.DefaultConfig(apiKey) + clientConfig.BaseURL = endpoint.BaseURL + "/v1" + client = openai.NewClientWithConfig(clientConfig) + } else { + // default to OpenAI + client = openai.NewClient(apiKey) } + + return &OpenAIAdapter{ + client: client, + model: model, + language: lang, + keywords: keywords, + providerName: providerName, + } +} + +// NewOpenAIAdapterFromConfig creates an adapter using the legacy Config struct +// This is for backwards compatibility during migration +func NewOpenAIAdapterFromConfig(config Config) *OpenAIAdapter { + return NewOpenAIAdapter(nil, config.APIKey, config.Model, config.Language, config.Keywords, "openai") } func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) { @@ -36,17 +69,20 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin return "", fmt.Errorf("convert to WAV: %w", err) } + // Convert language code to provider format + providerLang := language.ToProviderFormat(a.language, a.providerName) + // Create transcription request req := openai.AudioRequest{ - Model: a.config.Model, + Model: a.model, Reader: bytes.NewReader(wavData), FilePath: "audio.wav", - Language: a.config.Language, + Language: providerLang, } // Add keywords as initial_prompt to help with spelling hints - if len(a.config.Keywords) > 0 { - req.Prompt = strings.Join(a.config.Keywords, ", ") + if len(a.keywords) > 0 { + req.Prompt = strings.Join(a.keywords, ", ") } start := time.Now() @@ -54,10 +90,10 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin duration := time.Since(start) if err != nil { - log.Printf("openai-adapter: API call failed after %v: %v", duration, err) - return "", fmt.Errorf("openai transcription: %w", err) + log.Printf("%s-adapter: API call failed after %v: %v", a.providerName, duration, err) + return "", fmt.Errorf("%s transcription: %w", a.providerName, err) } - log.Printf("openai-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text) + log.Printf("%s-adapter: transcribed %d bytes in %v: %q", a.providerName, len(audioData), duration, resp.Text) return resp.Text, nil } diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index a321fa3..f5bfb04 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -38,7 +38,7 @@ func NewTranscriber(config Config) (Transcriber, error) { if config.APIKey == "" { return nil, fmt.Errorf("OpenAI API key required") } - adapter = NewOpenAIAdapter(config) + adapter = NewOpenAIAdapterFromConfig(config) case "groq-transcription": if config.APIKey == "" { diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go index 2aae67a..8b458e6 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/recording" ) @@ -498,6 +499,118 @@ func TestTranscriptionAdapter(t *testing.T) { } } +func TestOpenAIAdapter_Creation(t *testing.T) { + tests := []struct { + name string + endpoint *provider.EndpointConfig + apiKey string + model string + language string + keywords []string + providerName string + }{ + { + name: "openai with nil endpoint uses default", + endpoint: nil, + apiKey: "sk-test-key", + model: "whisper-1", + language: "en", + keywords: []string{"hello", "world"}, + providerName: "openai", + }, + { + name: "openai with explicit endpoint", + endpoint: &provider.EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"}, + apiKey: "sk-test-key", + model: "whisper-1", + language: "es", + keywords: nil, + providerName: "openai", + }, + { + name: "groq with custom endpoint", + endpoint: &provider.EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"}, + apiKey: "gsk-test-key", + model: "whisper-large-v3", + language: "fr", + keywords: []string{"bonjour"}, + providerName: "groq", + }, + { + name: "mistral with custom endpoint", + endpoint: &provider.EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"}, + apiKey: "mistral-test-key", + model: "voxtral-mini-latest", + language: "de", + keywords: nil, + providerName: "mistral", + }, + { + name: "auto language", + endpoint: nil, + apiKey: "sk-test-key", + model: "whisper-1", + language: "", // auto + keywords: nil, + providerName: "openai", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adapter := NewOpenAIAdapter(tt.endpoint, tt.apiKey, tt.model, tt.language, tt.keywords, tt.providerName) + if adapter == nil { + t.Errorf("NewOpenAIAdapter() returned nil") + return + } + + if adapter.model != tt.model { + t.Errorf("model = %q, want %q", adapter.model, tt.model) + } + + if adapter.language != tt.language { + t.Errorf("language = %q, want %q", adapter.language, tt.language) + } + + if adapter.providerName != tt.providerName { + t.Errorf("providerName = %q, want %q", adapter.providerName, tt.providerName) + } + + if len(adapter.keywords) != len(tt.keywords) { + t.Errorf("keywords len = %d, want %d", len(adapter.keywords), len(tt.keywords)) + } + }) + } +} + +func TestOpenAIAdapterFromConfig(t *testing.T) { + config := Config{ + Provider: "openai", + APIKey: "sk-test-key", + Model: "whisper-1", + Language: "en", + Keywords: []string{"test"}, + } + + adapter := NewOpenAIAdapterFromConfig(config) + if adapter == nil { + t.Errorf("NewOpenAIAdapterFromConfig() returned nil") + return + } + + if adapter.model != config.Model { + t.Errorf("model = %q, want %q", adapter.model, config.Model) + } + + if adapter.language != config.Language { + t.Errorf("language = %q, want %q", adapter.language, config.Language) + } + + if adapter.providerName != "openai" { + t.Errorf("providerName = %q, want %q", adapter.providerName, "openai") + } +} + // MockStreamingAdapter implements StreamingAdapter for testing type MockStreamingAdapter struct { StartFunc func(ctx context.Context, language string) error diff --git a/progress.txt b/progress.txt index aad1a8a..bb17e8a 100644 --- a/progress.txt +++ b/progress.txt @@ -104,3 +104,14 @@ Started: Sun Feb 1 12:22:47 AM CET 2026 - SupportedLanguages set to all 57 language codes - Added TestElevenLabsProvider test verifying all requirements - All tests passing, typecheck passes + +### Task 12: Create consolidated OpenAI-compatible BatchAdapter +- Refactored `internal/transcriber/adapter_openai.go` to be configurable +- New constructor: `NewOpenAIAdapter(endpoint *EndpointConfig, apiKey, model, lang string, keywords []string, providerName string)` +- Removed hardcoded base URL, now uses `endpoint.BaseURL + "/v1"` when endpoint provided +- Added `NewOpenAIAdapterFromConfig(config Config)` for backward compatibility during migration +- Language code converted to provider format via `language.ToProviderFormat(lang, providerName)` +- Log messages now include provider name for better debugging +- Added tests: `TestOpenAIAdapter_Creation`, `TestOpenAIAdapterFromConfig` +- Updated factory to use `NewOpenAIAdapterFromConfig` for now (will be updated in Task 15) +- All tests passing, typecheck passes diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index c79de61..8df67a9 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -313,7 +313,7 @@ "Transcribe() works with Groq endpoint (different BaseURL)", "Typecheck passes" ], - "passes": false + "passes": true }, { "title": "Remove redundant Groq and Mistral transcription adapters",