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
This commit is contained in:
+237
-36
@@ -20,6 +20,37 @@ type Config struct {
|
||||
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 {
|
||||
// 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":
|
||||
config.APIKey = os.Getenv("OPENAI_API_KEY")
|
||||
providerName = "openai"
|
||||
envVar = "OPENAI_API_KEY"
|
||||
case "groq-transcription", "groq-translation":
|
||||
config.APIKey = os.Getenv("GROQ_API_KEY")
|
||||
providerName = "groq"
|
||||
envVar = "GROQ_API_KEY"
|
||||
case "mistral-transcription":
|
||||
config.APIKey = os.Getenv("MISTRAL_API_KEY")
|
||||
providerName = "mistral"
|
||||
envVar = "MISTRAL_API_KEY"
|
||||
case "elevenlabs":
|
||||
config.APIKey = os.Getenv("ELEVENLABS_API_KEY")
|
||||
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"`
|
||||
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)
|
||||
}
|
||||
|
||||
// Migrate legacy mode-based config to backends
|
||||
if len(config.Injection.Backends) == 0 {
|
||||
// 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 {
|
||||
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 {
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@
|
||||
"Typecheck passes",
|
||||
"go test ./internal/config/... passes"
|
||||
],
|
||||
"passes": false
|
||||
"passes": true
|
||||
},
|
||||
{
|
||||
"title": "Create LLM adapter interface and implementations",
|
||||
|
||||
Reference in New Issue
Block a user