From e3bf09b02f80f68d61fd583995f7b1f8c9d508ab Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Sat, 31 Jan 2026 20:39:25 +0100 Subject: [PATCH] refactor config to unified provider structure with LLM support - add Providers map for centralized API key storage - add Keywords global field for transcription/LLM hints - add LLMConfig with Enabled, Provider, Model - add LLMPostProcessingConfig (RemoveStutters, AddPunctuation, FixGrammar, RemoveFillerWords) - add LLMCustomPromptConfig (Enabled, Prompt) - add ToLLMConfig() and IsLLMEnabled() methods - add auto-migration from old transcription.api_key format - unified API key resolution: providers -> legacy -> env var - backward compatible with existing configs --- internal/config/config.go | 291 +++++++++++++++++--- internal/config/config_test.go | 490 +++++++++++++++++++++++++++++++++ progress.txt | 32 +++ tasks/prd.jsonc | 2 +- 4 files changed, 769 insertions(+), 46 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index cb014ee..ca7ac6f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,10 +16,41 @@ import ( ) type Config struct { - Recording RecordingConfig `toml:"recording"` - Transcription TranscriptionConfig `toml:"transcription"` - Injection InjectionConfig `toml:"injection"` - Notifications NotificationsConfig `toml:"notifications"` + Recording RecordingConfig `toml:"recording"` + Transcription TranscriptionConfig `toml:"transcription"` + Injection InjectionConfig `toml:"injection"` + Notifications NotificationsConfig `toml:"notifications"` + Providers map[string]ProviderConfig `toml:"providers"` + Keywords []string `toml:"keywords"` + LLM LLMConfig `toml:"llm"` +} + +// ProviderConfig holds API key for a provider +type ProviderConfig struct { + APIKey string `toml:"api_key"` +} + +// LLMConfig configures the LLM post-processing phase +type LLMConfig struct { + Enabled bool `toml:"enabled"` + Provider string `toml:"provider"` + Model string `toml:"model"` + PostProcessing LLMPostProcessingConfig `toml:"post_processing"` + CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"` +} + +// LLMPostProcessingConfig controls text cleanup options +type LLMPostProcessingConfig struct { + RemoveStutters bool `toml:"remove_stutters"` + AddPunctuation bool `toml:"add_punctuation"` + FixGrammar bool `toml:"fix_grammar"` + RemoveFillerWords bool `toml:"remove_filler_words"` +} + +// LLMCustomPromptConfig allows custom prompts +type LLMCustomPromptConfig struct { + Enabled bool `toml:"enabled"` + Prompt string `toml:"prompt"` } type RecordingConfig struct { @@ -113,28 +144,124 @@ func (c *Config) ToRecordingConfig() recording.Config { func (c *Config) ToTranscriberConfig() transcriber.Config { config := transcriber.Config{ Provider: c.Transcription.Provider, - APIKey: c.Transcription.APIKey, Language: c.Transcription.Language, Model: c.Transcription.Model, } - // Check for API key in environment variables if not in config - if config.APIKey == "" { - switch c.Transcription.Provider { - case "openai": - config.APIKey = os.Getenv("OPENAI_API_KEY") - case "groq-transcription", "groq-translation": - config.APIKey = os.Getenv("GROQ_API_KEY") - case "mistral-transcription": - config.APIKey = os.Getenv("MISTRAL_API_KEY") - case "elevenlabs": - config.APIKey = os.Getenv("ELEVENLABS_API_KEY") + // Resolve API key: providers map -> legacy transcription.api_key -> environment variable + config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider) + + return config +} + +// resolveAPIKeyForProvider returns the API key for a provider from multiple sources +func (c *Config) resolveAPIKeyForProvider(provider string) string { + // Map transcription provider names to provider registry names + providerName := provider + envVar := "" + switch provider { + case "openai": + providerName = "openai" + envVar = "OPENAI_API_KEY" + case "groq-transcription", "groq-translation": + providerName = "groq" + envVar = "GROQ_API_KEY" + case "mistral-transcription": + providerName = "mistral" + envVar = "MISTRAL_API_KEY" + case "elevenlabs": + providerName = "elevenlabs" + envVar = "ELEVENLABS_API_KEY" + } + + // 1. Check providers map + if c.Providers != nil { + if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" { + return pc.APIKey } } + // 2. Check legacy transcription.api_key (backward compatibility) + if c.Transcription.APIKey != "" { + return c.Transcription.APIKey + } + + // 3. Check environment variable + if envVar != "" { + return os.Getenv(envVar) + } + + return "" +} + +// LLMAdapterConfig is the configuration passed to the LLM adapter +type LLMAdapterConfig struct { + Provider string + APIKey string + Model string + RemoveStutters bool + AddPunctuation bool + FixGrammar bool + RemoveFillerWords bool + CustomPrompt string + Keywords []string +} + +// ToLLMConfig returns the LLM adapter configuration +func (c *Config) ToLLMConfig() LLMAdapterConfig { + config := LLMAdapterConfig{ + Provider: c.LLM.Provider, + Model: c.LLM.Model, + RemoveStutters: c.LLM.PostProcessing.RemoveStutters, + AddPunctuation: c.LLM.PostProcessing.AddPunctuation, + FixGrammar: c.LLM.PostProcessing.FixGrammar, + RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords, + Keywords: c.Keywords, + } + + // Resolve API key for LLM provider + if c.LLM.Provider != "" { + config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider) + } + + // Add custom prompt if enabled + if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" { + config.CustomPrompt = c.LLM.CustomPrompt.Prompt + } + return config } +// resolveAPIKeyForLLMProvider returns the API key for an LLM provider +func (c *Config) resolveAPIKeyForLLMProvider(provider string) string { + envVar := "" + switch provider { + case "openai": + envVar = "OPENAI_API_KEY" + case "groq": + envVar = "GROQ_API_KEY" + } + + // 1. Check providers map + if c.Providers != nil { + if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" { + return pc.APIKey + } + } + + // 2. Check environment variable + if envVar != "" { + return os.Getenv(envVar) + } + + return "" +} + +// IsLLMEnabled returns true if LLM post-processing is enabled and configured +func (c *Config) IsLLMEnabled() bool { + return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != "" +} + func (c *Config) ToInjectionConfig() injection.Config { return injection.Config{ Backends: c.Injection.Backends, @@ -170,15 +297,13 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid transcription.provider: empty") } - // Validate provider-specific settings + // Validate provider-specific settings using unified API key resolution + apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider) + switch c.Transcription.Provider { case "openai": - apiKey := c.Transcription.APIKey if apiKey == "" { - apiKey = os.Getenv("OPENAI_API_KEY") - } - if apiKey == "" { - return fmt.Errorf("OpenAI API key required: not found in config (transcription.api_key) or environment variable (OPENAI_API_KEY)") + return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_API_KEY)") } // Validate language code if provided (empty string means auto-detect) @@ -187,12 +312,8 @@ func (c *Config) Validate() error { } case "groq-transcription": - apiKey := c.Transcription.APIKey if apiKey == "" { - apiKey = os.Getenv("GROQ_API_KEY") - } - if apiKey == "" { - return fmt.Errorf("Groq API key required: not found in config (transcription.api_key) or environment variable (GROQ_API_KEY)") + return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)") } // Validate language code if provided (empty string means auto-detect) @@ -207,12 +328,8 @@ func (c *Config) Validate() error { } case "groq-translation": - apiKey := c.Transcription.APIKey if apiKey == "" { - apiKey = os.Getenv("GROQ_API_KEY") - } - if apiKey == "" { - return fmt.Errorf("Groq API key required: not found in config (transcription.api_key) or environment variable (GROQ_API_KEY)") + return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)") } // For translation, language field hints at source language (output is always English) @@ -226,12 +343,8 @@ func (c *Config) Validate() error { } case "mistral-transcription": - apiKey := c.Transcription.APIKey if apiKey == "" { - apiKey = os.Getenv("MISTRAL_API_KEY") - } - if apiKey == "" { - return fmt.Errorf("Mistral API key required: not found in config (transcription.api_key) or environment variable (MISTRAL_API_KEY)") + return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, transcription.api_key) or environment variable (MISTRAL_API_KEY)") } // Validate language code if provided (empty string means auto-detect) @@ -246,12 +359,8 @@ func (c *Config) Validate() error { } case "elevenlabs": - apiKey := c.Transcription.APIKey if apiKey == "" { - apiKey = os.Getenv("ELEVENLABS_API_KEY") - } - if apiKey == "" { - return fmt.Errorf("ElevenLabs API key required: not found in config (transcription.api_key) or environment variable (ELEVENLABS_API_KEY)") + return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_API_KEY)") } // Validate language code if provided (empty string means auto-detect) @@ -273,6 +382,33 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid transcription.model: empty") } + // LLM (only validate if enabled) + if c.LLM.Enabled { + if c.LLM.Provider == "" { + return fmt.Errorf("llm.provider required when llm.enabled = true") + } + if c.LLM.Model == "" { + return fmt.Errorf("llm.model required when llm.enabled = true") + } + + // Validate LLM provider + validLLMProviders := map[string]bool{"openai": true, "groq": true} + if !validLLMProviders[c.LLM.Provider] { + return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider) + } + + // Check API key for LLM provider + llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider) + if llmAPIKey == "" { + switch c.LLM.Provider { + case "openai": + return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)") + case "groq": + return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)") + } + } + } + // Injection if len(c.Injection.Backends) == 0 { return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)") @@ -341,8 +477,14 @@ type legacyInjectionConfig struct { Mode string `toml:"mode"` } +// legacyTranscriptionConfig for migration from old api_key in transcription +type legacyTranscriptionConfig struct { + APIKey string `toml:"api_key"` +} + type legacyConfig struct { - Injection legacyInjectionConfig `toml:"injection"` + Injection legacyInjectionConfig `toml:"injection"` + Transcription legacyTranscriptionConfig `toml:"transcription"` } func Load() (*Config, error) { @@ -367,17 +509,76 @@ func Load() (*Config, error) { return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) } + // Parse legacy config for migrations + var legacy legacyConfig + toml.DecodeFile(configPath, &legacy) + // Migrate legacy mode-based config to backends if len(config.Injection.Backends) == 0 { - var legacy legacyConfig - toml.DecodeFile(configPath, &legacy) config.migrateInjectionMode(legacy.Injection.Mode) } + // Migrate legacy transcription.api_key to providers map + if legacy.Transcription.APIKey != "" && config.Providers == nil { + config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey) + } + + // Initialize providers map if nil + if config.Providers == nil { + config.Providers = make(map[string]ProviderConfig) + } + + // Set LLM defaults if not configured + config.applyLLMDefaults() + log.Printf("Config: configuration loaded successfully") return &config, nil } +// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map +func (c *Config) migrateTranscriptionAPIKey(apiKey string) { + if c.Providers == nil { + c.Providers = make(map[string]ProviderConfig) + } + + // Determine which provider this key is for based on transcription.provider + providerName := c.Transcription.Provider + switch providerName { + case "openai": + c.Providers["openai"] = ProviderConfig{APIKey: apiKey} + case "groq-transcription", "groq-translation": + c.Providers["groq"] = ProviderConfig{APIKey: apiKey} + case "mistral-transcription": + c.Providers["mistral"] = ProviderConfig{APIKey: apiKey} + case "elevenlabs": + c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey} + default: + // Unknown provider, try to guess based on key prefix + if len(apiKey) > 3 && apiKey[:3] == "sk-" { + c.Providers["openai"] = ProviderConfig{APIKey: apiKey} + } else if len(apiKey) > 4 && apiKey[:4] == "gsk_" { + c.Providers["groq"] = ProviderConfig{APIKey: apiKey} + } + } + + log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.") +} + +// applyLLMDefaults sets default values for LLM config +func (c *Config) applyLLMDefaults() { + // Default post-processing options to true if LLM is enabled and not explicitly set + // We detect "not set" by checking if all booleans are false (zero value) + // Since the default behavior should be all true, we only apply if everything is false + pp := &c.LLM.PostProcessing + if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords { + // Nothing was set, apply defaults + pp.RemoveStutters = true + pp.AddPunctuation = true + pp.FixGrammar = true + pp.RemoveFillerWords = true + } +} + // migrateInjectionMode converts old mode field to new backends array func (c *Config) migrateInjectionMode(mode string) { switch mode { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 97a2edb..336a849 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1280,3 +1280,493 @@ func TestMessagesConfig_Resolve_CustomOverrides(t *testing.T) { t.Errorf("MsgTranscribing title = %q, want %q", msgs[notify.MsgTranscribing].Title, "Hyprvoice") } } + +// Tests for new unified provider structure + +func TestConfig_ProvidersMap(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "openai", + Model: "whisper-1", + }, + Providers: map[string]ProviderConfig{ + "openai": {APIKey: "sk-provider-key"}, + }, + Injection: InjectionConfig{ + Backends: []string{"clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + }, + Notifications: NotificationsConfig{Type: "log"}, + } + + // Should resolve API key from providers map + transcriberConfig := config.ToTranscriberConfig() + if transcriberConfig.APIKey != "sk-provider-key" { + t.Errorf("Expected APIKey from providers map, got %s", transcriberConfig.APIKey) + } + + // Validation should pass + if err := config.Validate(); err != nil { + t.Errorf("Validate() error = %v", err) + } +} + +func TestConfig_ProvidersMapFallbackToLegacy(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "openai", + APIKey: "sk-legacy-key", // Legacy field + Model: "whisper-1", + }, + Providers: map[string]ProviderConfig{}, // Empty providers map + Injection: InjectionConfig{ + Backends: []string{"clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + }, + Notifications: NotificationsConfig{Type: "log"}, + } + + // Should fall back to legacy transcription.api_key + transcriberConfig := config.ToTranscriberConfig() + if transcriberConfig.APIKey != "sk-legacy-key" { + t.Errorf("Expected APIKey from legacy field, got %s", transcriberConfig.APIKey) + } +} + +func TestConfig_LLMConfig(t *testing.T) { + config := &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "openai", + Model: "whisper-1", + }, + Providers: map[string]ProviderConfig{ + "openai": {APIKey: "sk-test-key"}, + }, + Keywords: []string{"hyprvoice", "Claude"}, + LLM: LLMConfig{ + Enabled: true, + Provider: "openai", + Model: "gpt-4o-mini", + PostProcessing: LLMPostProcessingConfig{ + RemoveStutters: true, + AddPunctuation: true, + FixGrammar: false, + RemoveFillerWords: true, + }, + CustomPrompt: LLMCustomPromptConfig{ + Enabled: true, + Prompt: "Format as code", + }, + }, + Injection: InjectionConfig{ + Backends: []string{"clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + }, + Notifications: NotificationsConfig{Type: "log"}, + } + + // Validate should pass + if err := config.Validate(); err != nil { + t.Errorf("Validate() error = %v", err) + } + + // IsLLMEnabled should return true + if !config.IsLLMEnabled() { + t.Error("IsLLMEnabled() should return true") + } + + // ToLLMConfig should return correct values + llmConfig := config.ToLLMConfig() + if llmConfig.Provider != "openai" { + t.Errorf("LLM provider = %s, want openai", llmConfig.Provider) + } + if llmConfig.Model != "gpt-4o-mini" { + t.Errorf("LLM model = %s, want gpt-4o-mini", llmConfig.Model) + } + if llmConfig.APIKey != "sk-test-key" { + t.Errorf("LLM APIKey = %s, want sk-test-key", llmConfig.APIKey) + } + if !llmConfig.RemoveStutters { + t.Error("RemoveStutters should be true") + } + if llmConfig.FixGrammar { + t.Error("FixGrammar should be false") + } + if llmConfig.CustomPrompt != "Format as code" { + t.Errorf("CustomPrompt = %s, want 'Format as code'", llmConfig.CustomPrompt) + } + if len(llmConfig.Keywords) != 2 { + t.Errorf("Keywords length = %d, want 2", len(llmConfig.Keywords)) + } +} + +func TestConfig_LLMValidation(t *testing.T) { + baseConfig := func() *Config { + return &Config{ + Recording: RecordingConfig{ + SampleRate: 16000, + Channels: 1, + Format: "s16", + BufferSize: 8192, + ChannelBufferSize: 30, + Timeout: time.Minute, + }, + Transcription: TranscriptionConfig{ + Provider: "openai", + Model: "whisper-1", + }, + Providers: map[string]ProviderConfig{ + "openai": {APIKey: "sk-test-key"}, + }, + Injection: InjectionConfig{ + Backends: []string{"clipboard"}, + YdotoolTimeout: 5 * time.Second, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + }, + Notifications: NotificationsConfig{Type: "log"}, + } + } + + t.Run("LLM enabled without provider fails", func(t *testing.T) { + config := baseConfig() + config.LLM.Enabled = true + config.LLM.Model = "gpt-4o-mini" + // No provider set + + err := config.Validate() + if err == nil { + t.Error("Validate() should fail when LLM enabled without provider") + } + }) + + t.Run("LLM enabled without model fails", func(t *testing.T) { + config := baseConfig() + config.LLM.Enabled = true + config.LLM.Provider = "openai" + // No model set + + err := config.Validate() + if err == nil { + t.Error("Validate() should fail when LLM enabled without model") + } + }) + + t.Run("LLM enabled with invalid provider fails", func(t *testing.T) { + config := baseConfig() + config.LLM.Enabled = true + config.LLM.Provider = "invalid" + config.LLM.Model = "some-model" + + err := config.Validate() + if err == nil { + t.Error("Validate() should fail with invalid LLM provider") + } + }) + + t.Run("LLM disabled skips validation", func(t *testing.T) { + config := baseConfig() + config.LLM.Enabled = false + config.LLM.Provider = "invalid" // Would fail if validated + + err := config.Validate() + if err != nil { + t.Errorf("Validate() should pass when LLM disabled: %v", err) + } + }) + + t.Run("LLM enabled without API key fails", func(t *testing.T) { + config := baseConfig() + config.LLM.Enabled = true + config.LLM.Provider = "groq" + config.LLM.Model = "llama-3.3-70b-versatile" + // No groq API key in providers + + // Clear env var + orig := os.Getenv("GROQ_API_KEY") + os.Unsetenv("GROQ_API_KEY") + defer func() { + if orig != "" { + os.Setenv("GROQ_API_KEY", orig) + } + }() + + err := config.Validate() + if err == nil { + t.Error("Validate() should fail when LLM enabled without API key for provider") + } + }) +} + +func TestConfig_MigrateTranscriptionAPIKey(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") + + err := os.MkdirAll(filepath.Dir(configPath), 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // Old-style config with api_key in transcription + oldConfig := `[recording] +sample_rate = 16000 +channels = 1 +format = "s16" +buffer_size = 8192 +channel_buffer_size = 30 +timeout = "5m" + +[transcription] +provider = "openai" +api_key = "sk-old-style-key" +model = "whisper-1" + +[injection] +backends = ["clipboard"] +ydotool_timeout = "5s" +wtype_timeout = "5s" +clipboard_timeout = "3s" + +[notifications] +type = "log"` + + err = os.WriteFile(configPath, []byte(oldConfig), 0644) + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + originalConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer func() { + if originalConfigDir == "" { + os.Unsetenv("XDG_CONFIG_HOME") + } else { + os.Setenv("XDG_CONFIG_HOME", originalConfigDir) + } + }() + + config, err := Load() + if err != nil { + t.Errorf("Load() error = %v", err) + return + } + + // Should have migrated to providers map + if config.Providers == nil { + t.Fatal("Providers map should not be nil after migration") + } + if config.Providers["openai"].APIKey != "sk-old-style-key" { + t.Errorf("Expected migrated API key in providers.openai, got %s", config.Providers["openai"].APIKey) + } + + // Validation should pass + if err := config.Validate(); err != nil { + t.Errorf("Validate() should pass after migration: %v", err) + } + + // ToTranscriberConfig should resolve correctly + transcriberConfig := config.ToTranscriberConfig() + if transcriberConfig.APIKey != "sk-old-style-key" { + t.Errorf("Expected APIKey 'sk-old-style-key', got %s", transcriberConfig.APIKey) + } +} + +func TestConfig_NewStyleConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") + + err := os.MkdirAll(filepath.Dir(configPath), 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + // New-style config with providers map + newConfig := `keywords = ["Claude", "hyprvoice"] + +[recording] +sample_rate = 16000 +channels = 1 +format = "s16" +buffer_size = 8192 +channel_buffer_size = 30 +timeout = "5m" + +[providers.openai] +api_key = "sk-new-style-key" + +[providers.groq] +api_key = "gsk_new-groq-key" + +[transcription] +provider = "openai" +model = "whisper-1" + +[llm] +enabled = true +provider = "groq" +model = "llama-3.3-70b-versatile" + +[llm.post_processing] +remove_stutters = true +add_punctuation = true +fix_grammar = true +remove_filler_words = false + +[injection] +backends = ["clipboard"] +ydotool_timeout = "5s" +wtype_timeout = "5s" +clipboard_timeout = "3s" + +[notifications] +type = "log"` + + err = os.WriteFile(configPath, []byte(newConfig), 0644) + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + originalConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer func() { + if originalConfigDir == "" { + os.Unsetenv("XDG_CONFIG_HOME") + } else { + os.Setenv("XDG_CONFIG_HOME", originalConfigDir) + } + }() + + config, err := Load() + if err != nil { + t.Errorf("Load() error = %v", err) + return + } + + // Providers should be loaded + if config.Providers["openai"].APIKey != "sk-new-style-key" { + t.Errorf("Expected openai API key, got %s", config.Providers["openai"].APIKey) + } + if config.Providers["groq"].APIKey != "gsk_new-groq-key" { + t.Errorf("Expected groq API key, got %s", config.Providers["groq"].APIKey) + } + + // Keywords should be loaded + if len(config.Keywords) != 2 { + t.Errorf("Expected 2 keywords, got %d", len(config.Keywords)) + } + + // LLM config should be loaded + if !config.LLM.Enabled { + t.Error("LLM should be enabled") + } + if config.LLM.Provider != "groq" { + t.Errorf("LLM provider = %s, want groq", config.LLM.Provider) + } + if !config.LLM.PostProcessing.RemoveStutters { + t.Error("RemoveStutters should be true") + } + if config.LLM.PostProcessing.RemoveFillerWords { + t.Error("RemoveFillerWords should be false") + } + + // Validation should pass + if err := config.Validate(); err != nil { + t.Errorf("Validate() error = %v", err) + } + + // ToTranscriberConfig should use openai key + transcriberConfig := config.ToTranscriberConfig() + if transcriberConfig.APIKey != "sk-new-style-key" { + t.Errorf("Transcriber APIKey = %s, want sk-new-style-key", transcriberConfig.APIKey) + } + + // ToLLMConfig should use groq key + llmConfig := config.ToLLMConfig() + if llmConfig.APIKey != "gsk_new-groq-key" { + t.Errorf("LLM APIKey = %s, want gsk_new-groq-key", llmConfig.APIKey) + } +} + +func TestConfig_LLMDefaults(t *testing.T) { + config := &Config{ + LLM: LLMConfig{ + Enabled: true, + Provider: "openai", + Model: "gpt-4o-mini", + // PostProcessing left as zero values + }, + } + + // Simulate what Load() does + config.applyLLMDefaults() + + // All post-processing options should default to true + if !config.LLM.PostProcessing.RemoveStutters { + t.Error("RemoveStutters should default to true") + } + if !config.LLM.PostProcessing.AddPunctuation { + t.Error("AddPunctuation should default to true") + } + if !config.LLM.PostProcessing.FixGrammar { + t.Error("FixGrammar should default to true") + } + if !config.LLM.PostProcessing.RemoveFillerWords { + t.Error("RemoveFillerWords should default to true") + } +} + +func TestConfig_LLMDefaultsPreserveExplicit(t *testing.T) { + config := &Config{ + LLM: LLMConfig{ + Enabled: true, + Provider: "openai", + Model: "gpt-4o-mini", + PostProcessing: LLMPostProcessingConfig{ + RemoveStutters: true, // One is set + // Others are false + }, + }, + } + + // Simulate what Load() does + config.applyLLMDefaults() + + // Should preserve the explicit setting and not override + if !config.LLM.PostProcessing.RemoveStutters { + t.Error("RemoveStutters should remain true") + } + // Since at least one is true, defaults should NOT be applied + if config.LLM.PostProcessing.AddPunctuation { + t.Error("AddPunctuation should remain false (explicit)") + } +} diff --git a/progress.txt b/progress.txt index c4100d7..96b975d 100644 --- a/progress.txt +++ b/progress.txt @@ -15,3 +15,35 @@ 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 + +## Task 2: Refactor config to unified provider structure - COMPLETE + +Added to internal/config/config.go: +- `Providers map[string]ProviderConfig` for centralized API key storage +- `Keywords []string` at config root level +- `LLMConfig` with Enabled, Provider, Model, PostProcessing, CustomPrompt +- `LLMPostProcessingConfig` with RemoveStutters, AddPunctuation, FixGrammar, RemoveFillerWords (all default true) +- `LLMCustomPromptConfig` with Enabled, Prompt +- `LLMAdapterConfig` struct for passing to LLM adapters +- `ToLLMConfig()` method +- `IsLLMEnabled()` helper +- `resolveAPIKeyForProvider()` - unified API key resolution: providers map -> legacy transcription.api_key -> env var +- `resolveAPIKeyForLLMProvider()` - same for LLM +- `migrateTranscriptionAPIKey()` - auto-migrates old config format +- `applyLLMDefaults()` - sets post-processing options to true if all are zero + +Migration: +- Old configs with `transcription.api_key` auto-migrate to `providers` map on Load() +- Logs warning: "Run 'hyprvoice configure' to update config format" +- Both old and new config formats work (backward compatible) + +Validation: +- LLM validation only runs when `llm.enabled = true` +- Checks provider is openai or groq +- Checks API key is available for LLM provider + +Key decisions: +- API key resolution order: providers.X.api_key -> transcription.api_key -> ENV_VAR +- LLM provider names are "openai" and "groq" (not "groq-transcription") +- PostProcessing defaults to all true only if ALL options are false (zero values) +- Keywords at root level (global), used by both transcription and LLM diff --git a/tasks/prd.jsonc b/tasks/prd.jsonc index 9cd89b5..dd797ad 100644 --- a/tasks/prd.jsonc +++ b/tasks/prd.jsonc @@ -47,7 +47,7 @@ "Typecheck passes", "go test ./internal/config/... passes" ], - "passes": false + "passes": true }, { "title": "Create LLM adapter interface and implementations",