@@ -696,6 +696,31 @@ func TestConfig_ConversionMethods(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_LlamaSwap(t *testing.T) {
|
||||
cfg := createTestConfig()
|
||||
cfg.Transcription.Provider = "llama-swap"
|
||||
cfg.Transcription.Model = "whisper-large-v3-turbo"
|
||||
cfg.Providers = map[string]ProviderConfig{
|
||||
"llama-swap": {APIKey: "test-key", BaseURL: "http://192.168.1.50:8080"},
|
||||
}
|
||||
cfg.LLM = LLMConfig{Enabled: true, Provider: "llama-swap", Model: "qwen3"}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if got := cfg.ToTranscriberConfig().BaseURL; got != "http://192.168.1.50:8080" {
|
||||
t.Errorf("transcriber BaseURL = %q", got)
|
||||
}
|
||||
if got := cfg.ToLLMConfig().BaseURL; got != "http://192.168.1.50:8080" {
|
||||
t.Errorf("LLM BaseURL = %q", got)
|
||||
}
|
||||
|
||||
cfg.Providers["llama-swap"] = ProviderConfig{APIKey: "test-key", BaseURL: "http://192.168.1.50:8080/v1"}
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "omit the /v1 suffix") {
|
||||
t.Errorf("Validate() error = %v, want /v1 validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateModelLanguageCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -32,6 +32,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
|
||||
}
|
||||
|
||||
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
|
||||
if c.Transcription.Provider == provider.ProviderLlamaSwap {
|
||||
config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -74,6 +77,9 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig {
|
||||
if c.LLM.Provider != "" {
|
||||
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
|
||||
}
|
||||
if c.LLM.Provider == provider.ProviderLlamaSwap {
|
||||
config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL
|
||||
}
|
||||
|
||||
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
|
||||
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
|
||||
|
||||
+12
-3
@@ -47,6 +47,9 @@ func Save(cfg *Config) error {
|
||||
for name, pc := range cfg.Providers {
|
||||
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
|
||||
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey))
|
||||
if pc.BaseURL != "" {
|
||||
sb.WriteString(fmt.Sprintf(" base_url = %q\n", pc.BaseURL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
@@ -221,6 +224,9 @@ keywords = []
|
||||
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
|
||||
# [providers.deepgram]
|
||||
# api_key = "" # Deepgram API key (or set DEEPGRAM_API_KEY env var)
|
||||
# [providers.llama-swap]
|
||||
# api_key = "" # Or set LLAMA_SWAP_API_KEY
|
||||
# base_url = "http://llama-swap.example:8080" # No /v1 suffix
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Audio Recording
|
||||
@@ -241,7 +247,7 @@ keywords = []
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[transcription]
|
||||
provider = "openai" # "openai", "groq-transcription", "mistral-transcription", "elevenlabs", "whisper-cpp"
|
||||
provider = "openai" # Also "llama-swap" for a remote OpenAI-compatible LlamaSwap server
|
||||
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
|
||||
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
|
||||
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
|
||||
@@ -253,7 +259,7 @@ keywords = []
|
||||
|
||||
[llm]
|
||||
enabled = true # Enable LLM post-processing (highly recommended)
|
||||
provider = "openai" # "openai" or "groq" (must have API key configured above)
|
||||
provider = "openai" # "openai", "groq", or "llama-swap" (must have API key configured above)
|
||||
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
|
||||
|
||||
[llm.post_processing]
|
||||
@@ -272,7 +278,7 @@ keywords = []
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[injection]
|
||||
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
|
||||
backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
|
||||
ydotool_timeout = "5s" # Timeout for ydotool commands
|
||||
wtype_timeout = "5s" # Timeout for wtype commands
|
||||
clipboard_timeout = "3s" # Timeout for clipboard operations
|
||||
@@ -322,12 +328,15 @@ keywords = []
|
||||
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
|
||||
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
|
||||
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2, scribe_v2_realtime)
|
||||
# - "llama-swap": Remote OpenAI-compatible LlamaSwap (any configured transcription model)
|
||||
#
|
||||
# LLM providers (for post-processing):
|
||||
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
|
||||
# - "groq": Fast inference (llama-3.3-70b-versatile recommended)
|
||||
# - "llama-swap": Any chat model configured in your LlamaSwap server
|
||||
#
|
||||
# Injection backends:
|
||||
# - "clipboard-paste": Temporarily uses the regular clipboard + wtype Ctrl+V, then restores prior text clipboard.
|
||||
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
|
||||
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
|
||||
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
|
||||
|
||||
@@ -25,7 +25,8 @@ type Config struct {
|
||||
|
||||
// ProviderConfig holds API key for a provider
|
||||
type ProviderConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
APIKey string `toml:"api_key"`
|
||||
BaseURL string `toml:"base_url"` // OpenAI-compatible base URL, without /v1
|
||||
}
|
||||
|
||||
// LLMConfig configures the LLM post-processing phase
|
||||
@@ -139,4 +140,5 @@ type LLMAdapterConfig struct {
|
||||
RemoveFillerWords bool
|
||||
CustomPrompt string
|
||||
Keywords []string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
+63
-27
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
@@ -34,6 +35,8 @@ func envVarForProvider(registryName string) string {
|
||||
return "ELEVENLABS_API_KEY"
|
||||
case "deepgram":
|
||||
return "DEEPGRAM_API_KEY"
|
||||
case "llama-swap":
|
||||
return "LLAMA_SWAP_API_KEY"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -82,27 +85,40 @@ func (c *Config) Validate() error {
|
||||
strings.Title(registryName), registryName, envVar)
|
||||
}
|
||||
}
|
||||
if registryName == provider.ProviderLlamaSwap {
|
||||
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// validate model exists
|
||||
if c.Transcription.Model == "" {
|
||||
return fmt.Errorf("invalid transcription.model: empty")
|
||||
}
|
||||
|
||||
// validate model exists in provider
|
||||
_, err := provider.GetModel(registryName, c.Transcription.Model)
|
||||
if err != nil {
|
||||
models := provider.ModelsOfType(p, provider.Transcription)
|
||||
modelIDs := make([]string, len(models))
|
||||
for i, m := range models {
|
||||
modelIDs[i] = m.ID
|
||||
// LlamaSwap is an OpenAI-compatible router: its model IDs are defined by the
|
||||
// remote server, so they cannot be validated against Hyprvoice's static registry.
|
||||
if registryName == provider.ProviderLlamaSwap {
|
||||
if c.Transcription.Streaming {
|
||||
return fmt.Errorf("llama-swap transcription supports batch mode only (set transcription.streaming = false)")
|
||||
}
|
||||
} else {
|
||||
// validate model exists in provider
|
||||
_, err := provider.GetModel(registryName, c.Transcription.Model)
|
||||
if err != nil {
|
||||
models := provider.ModelsOfType(p, provider.Transcription)
|
||||
modelIDs := make([]string, len(models))
|
||||
for i, m := range models {
|
||||
modelIDs[i] = m.ID
|
||||
}
|
||||
return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", "))
|
||||
}
|
||||
return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", "))
|
||||
}
|
||||
|
||||
// validate language-model compatibility using effective language (transcription overrides general)
|
||||
effectiveLanguage := c.resolveEffectiveLanguage()
|
||||
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
|
||||
return err
|
||||
// validate language-model compatibility using effective language (transcription overrides general)
|
||||
effectiveLanguage := c.resolveEffectiveLanguage()
|
||||
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// LLM validation
|
||||
@@ -121,20 +137,26 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", "))
|
||||
}
|
||||
|
||||
// validate LLM model exists
|
||||
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
|
||||
if err != nil {
|
||||
models := provider.ModelsOfType(llmProvider, provider.LLM)
|
||||
modelIDs := make([]string, len(models))
|
||||
for i, m := range models {
|
||||
modelIDs[i] = m.ID
|
||||
if c.LLM.Provider == provider.ProviderLlamaSwap {
|
||||
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// validate LLM model exists
|
||||
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
|
||||
if err != nil {
|
||||
models := provider.ModelsOfType(llmProvider, provider.LLM)
|
||||
modelIDs := make([]string, len(models))
|
||||
for i, m := range models {
|
||||
modelIDs[i] = m.ID
|
||||
}
|
||||
return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", "))
|
||||
}
|
||||
return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", "))
|
||||
}
|
||||
|
||||
// verify model is actually an LLM
|
||||
if llmModel.Type != provider.LLM {
|
||||
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
|
||||
// verify model is actually an LLM
|
||||
if llmModel.Type != provider.LLM {
|
||||
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// validate LLM API key
|
||||
@@ -151,10 +173,10 @@ func (c *Config) Validate() error {
|
||||
if len(c.Injection.Backends) == 0 {
|
||||
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
|
||||
}
|
||||
validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true}
|
||||
validBackends := map[string]bool{"clipboard-paste": true, "ydotool": true, "wtype": true, "clipboard": true}
|
||||
for _, backend := range c.Injection.Backends {
|
||||
if !validBackends[backend] {
|
||||
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend)
|
||||
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be clipboard-paste, ydotool, wtype, or clipboard)", backend)
|
||||
}
|
||||
}
|
||||
if c.Injection.YdotoolTimeout <= 0 {
|
||||
@@ -175,6 +197,20 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLlamaSwapBaseURL(baseURL string) error {
|
||||
if baseURL == "" {
|
||||
return fmt.Errorf("llama-swap base_url required in providers.llama-swap.base_url")
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("invalid llama-swap base_url: %q (expected http://host:port, without /v1)", baseURL)
|
||||
}
|
||||
if strings.TrimRight(u.Path, "/") == "/v1" {
|
||||
return fmt.Errorf("invalid llama-swap base_url: omit the /v1 suffix")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateModelLanguageCompatibility validates that a model supports the given language.
|
||||
// Returns error if the language is not supported, nil if supported or if langCode is empty (auto).
|
||||
func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error {
|
||||
|
||||
Reference in New Issue
Block a user