LLMIFY THIS MOFO
CI / Test (push) Successful in 58s

This commit is contained in:
2026-09-01 01:09:59 -04:00
parent dad09d1109
commit c89ff2b68e
18 changed files with 288 additions and 41 deletions
+30 -2
View File
@@ -91,12 +91,16 @@ Hyprvoice uses a unified provider system where API keys are configured once and
[providers.deepgram] [providers.deepgram]
api_key = "..." # Or set DEEPGRAM_API_KEY env var 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" # Do not include /v1
``` ```
**API key resolution order:** **API key resolution order:**
1. `[providers.X]` section in config 1. `[providers.X]` section in config
2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.) 2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, `LLAMA_SWAP_API_KEY`, etc.)
## Transcription Providers ## Transcription Providers
@@ -104,6 +108,29 @@ Hyprvoice supports multiple transcription backends. See [docs/providers.md](./pr
### Cloud Providers ### Cloud Providers
### LlamaSwap (network-hosted OpenAI-compatible models)
LlamaSwap proxies OpenAI-compatible endpoints, including `/v1/audio/transcriptions` and `/v1/chat/completions`. Configure its host once, then set the model IDs exactly as they appear in LlamaSwap's `/v1/models` response:
```toml
[providers.llama-swap]
api_key = "your-llama-swap-api-key"
base_url = "http://192.168.1.50:8080" # No trailing /v1
[transcription]
provider = "llama-swap"
model = "whisper-large-v3-turbo"
language = ""
streaming = false
[llm]
enabled = true
provider = "llama-swap"
model = "your-chat-model-id"
```
The transcription and chat model IDs are deliberately not restricted by Hyprvoice: LlamaSwap selects from the models configured on your server. Hyprvoice submits transcription after recording ends, so set `streaming = false`.
### OpenAI Whisper API ### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API: Cloud-based transcription using OpenAI's Whisper API:
@@ -455,7 +482,7 @@ Configurable text injection with multiple backends:
```toml ```toml
[injection] [injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain backends = ["clipboard-paste", "ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s" ydotool_timeout = "5s"
wtype_timeout = "5s" wtype_timeout = "5s"
clipboard_timeout = "3s" clipboard_timeout = "3s"
@@ -464,6 +491,7 @@ clipboard_timeout = "3s"
### Injection Backends ### Injection Backends
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps. - **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`clipboard-paste`**: Copies text to the primary selection then sends Shift+Insert through ydotool. Fast, layout-independent, and preserves the regular clipboard; recommended for Dvorak/Colemak users.
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug). - **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug).
- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste. - **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste.
+25
View File
@@ -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) { func TestValidateModelLanguageCompatibility(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+6
View File
@@ -32,6 +32,9 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
} }
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider) config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
if c.Transcription.Provider == provider.ProviderLlamaSwap {
config.BaseURL = c.Providers[provider.ProviderLlamaSwap].BaseURL
}
return config return config
} }
@@ -74,6 +77,9 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig {
if c.LLM.Provider != "" { if c.LLM.Provider != "" {
config.APIKey = c.resolveAPIKeyForLLMProvider(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 != "" { if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
config.CustomPrompt = c.LLM.CustomPrompt.Prompt config.CustomPrompt = c.LLM.CustomPrompt.Prompt
+12 -3
View File
@@ -47,6 +47,9 @@ func Save(cfg *Config) error {
for name, pc := range cfg.Providers { for name, pc := range cfg.Providers {
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name)) sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey)) 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") sb.WriteString("\n")
} }
} }
@@ -221,6 +224,9 @@ keywords = []
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var) # api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
# [providers.deepgram] # [providers.deepgram]
# api_key = "" # Deepgram API key (or set DEEPGRAM_API_KEY env var) # 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 # Audio Recording
@@ -241,7 +247,7 @@ keywords = []
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
[transcription] [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" 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. 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) threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
@@ -253,7 +259,7 @@ keywords = []
[llm] [llm]
enabled = true # Enable LLM post-processing (highly recommended) 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" model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
[llm.post_processing] [llm.post_processing]
@@ -272,7 +278,7 @@ keywords = []
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
[injection] [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 ydotool_timeout = "5s" # Timeout for ydotool commands
wtype_timeout = "5s" # Timeout for wtype commands wtype_timeout = "5s" # Timeout for wtype commands
clipboard_timeout = "3s" # Timeout for clipboard operations 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) # - "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) # - "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) # - "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): # LLM providers (for post-processing):
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance) # - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
# - "groq": Fast inference (llama-3.3-70b-versatile recommended) # - "groq": Fast inference (llama-3.3-70b-versatile recommended)
# - "llama-swap": Any chat model configured in your LlamaSwap server
# #
# Injection backends: # Injection backends:
# - "clipboard-paste": Uses primary selection + ydotool Shift+Insert; fast, layout-independent, and preserves clipboard.
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps. # - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps. # - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste). # - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
+2
View File
@@ -26,6 +26,7 @@ type Config struct {
// ProviderConfig holds API key for a provider // ProviderConfig holds API key for a provider
type ProviderConfig struct { 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 // LLMConfig configures the LLM post-processing phase
@@ -139,4 +140,5 @@ type LLMAdapterConfig struct {
RemoveFillerWords bool RemoveFillerWords bool
CustomPrompt string CustomPrompt string
Keywords []string Keywords []string
BaseURL string
} }
+38 -2
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"strings" "strings"
"github.com/leonardotrapani/hyprvoice/internal/provider" "github.com/leonardotrapani/hyprvoice/internal/provider"
@@ -34,6 +35,8 @@ func envVarForProvider(registryName string) string {
return "ELEVENLABS_API_KEY" return "ELEVENLABS_API_KEY"
case "deepgram": case "deepgram":
return "DEEPGRAM_API_KEY" return "DEEPGRAM_API_KEY"
case "llama-swap":
return "LLAMA_SWAP_API_KEY"
default: default:
return "" return ""
} }
@@ -82,12 +85,24 @@ func (c *Config) Validate() error {
strings.Title(registryName), registryName, envVar) strings.Title(registryName), registryName, envVar)
} }
} }
if registryName == provider.ProviderLlamaSwap {
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
return err
}
}
// validate model exists // validate model exists
if c.Transcription.Model == "" { if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty") return fmt.Errorf("invalid transcription.model: empty")
} }
// 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 // validate model exists in provider
_, err := provider.GetModel(registryName, c.Transcription.Model) _, err := provider.GetModel(registryName, c.Transcription.Model)
if err != nil { if err != nil {
@@ -104,6 +119,7 @@ func (c *Config) Validate() error {
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil { if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
return err return err
} }
}
// LLM validation // LLM validation
if c.LLM.Enabled { if c.LLM.Enabled {
@@ -121,6 +137,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", ")) return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", "))
} }
if c.LLM.Provider == provider.ProviderLlamaSwap {
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
return err
}
} else {
// validate LLM model exists // validate LLM model exists
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model) llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
if err != nil { if err != nil {
@@ -136,6 +157,7 @@ func (c *Config) Validate() error {
if llmModel.Type != provider.LLM { if llmModel.Type != provider.LLM {
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model) return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
} }
}
// validate LLM API key // validate LLM API key
if llmProvider.RequiresAPIKey() { if llmProvider.RequiresAPIKey() {
@@ -151,10 +173,10 @@ func (c *Config) Validate() error {
if len(c.Injection.Backends) == 0 { if len(c.Injection.Backends) == 0 {
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)") 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 { for _, backend := range c.Injection.Backends {
if !validBackends[backend] { 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 { if c.Injection.YdotoolTimeout <= 0 {
@@ -175,6 +197,20 @@ func (c *Config) Validate() error {
return nil 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. // 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). // Returns error if the language is not supported, nil if supported or if langCode is empty (auto).
func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error { func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error {
+76
View File
@@ -0,0 +1,76 @@
package injection
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// clipboardPasteBackend copies text to the primary selection and pastes it
// with ydotool's layout-independent Shift+Insert key codes.
//
// This preserves the regular clipboard, is independent of the active keyboard
// layout, and delivers the whole string at once.
type clipboardPasteBackend struct {
clipboard *clipboardBackend
ydotool *ydotoolBackend
}
func NewClipboardPasteBackend() Backend {
return &clipboardPasteBackend{
clipboard: NewClipboardBackend().(*clipboardBackend),
ydotool: NewYdotoolBackend().(*ydotoolBackend),
}
}
func (c *clipboardPasteBackend) Name() string {
return "clipboard-paste"
}
func (c *clipboardPasteBackend) Available() error {
if err := c.clipboard.Available(); err != nil {
return err
}
if err := c.ydotool.Available(); err != nil {
return err
}
return nil
}
func (c *clipboardPasteBackend) Inject(ctx context.Context, text string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := c.Available(); err != nil {
return err
}
// Shift+Insert pastes the primary selection, leaving the user's regular
// clipboard untouched.
primaryCopy := exec.CommandContext(ctx, "wl-copy", "--primary")
primaryCopy.Stdin = strings.NewReader(text)
if err := primaryCopy.Run(); err != nil {
return fmt.Errorf("copy transcription to primary selection: %w", err)
}
// wl-copy returns before every client has observed the new selection. Give
// the compositor a short chance to publish it; this is still perceived as
// an immediate paste and avoids inserting the previous clipboard contents.
select {
case <-time.After(150 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
// Linux input event codes: KEY_LEFTSHIFT=42, KEY_INSERT=110. These keys do
// not depend on the alphabetic layout, and Shift+Insert is widely supported.
cmd := exec.CommandContext(ctx, "ydotool", "key", "42:1", "110:1", "110:0", "42:0")
if socketPath := c.ydotool.getSocketPath(); socketPath != "" {
cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
}
if err := cmd.Run(); err != nil {
return fmt.Errorf("paste primary selection with ydotool: %w", err)
}
return nil
}
+3 -1
View File
@@ -12,7 +12,7 @@ type Injector interface {
} }
type Config struct { type Config struct {
Backends []string // Ordered list: "ydotool", "wtype", "clipboard" Backends []string // Ordered list: "clipboard-paste", "ydotool", "wtype", "clipboard"
YdotoolTimeout time.Duration // Timeout for ydotool commands YdotoolTimeout time.Duration // Timeout for ydotool commands
WtypeTimeout time.Duration // Timeout for wtype commands WtypeTimeout time.Duration // Timeout for wtype commands
ClipboardTimeout time.Duration // Timeout for clipboard operations ClipboardTimeout time.Duration // Timeout for clipboard operations
@@ -34,6 +34,8 @@ func NewInjector(config Config) Injector {
backends = append(backends, NewWtypeBackend()) backends = append(backends, NewWtypeBackend())
case "clipboard": case "clipboard":
backends = append(backends, NewClipboardBackend()) backends = append(backends, NewClipboardBackend())
case "clipboard-paste":
backends = append(backends, NewClipboardPasteBackend())
default: default:
log.Printf("Injection: unknown backend %q, skipping", name) log.Printf("Injection: unknown backend %q, skipping", name)
} }
+11 -3
View File
@@ -66,9 +66,10 @@ func TestNewInjector_IgnoresUnknownBackends(t *testing.T) {
} }
func TestInjector_Inject(t *testing.T) { func TestInjector_Inject(t *testing.T) {
// Skip integration tests in CI environments // This test invokes real Wayland input tools and must run in a graphical
if os.Getenv("CI") == "true" { // session, not merely outside CI.
t.Skip("Skipping integration test in CI environment") if os.Getenv("CI") == "true" || os.Getenv("WAYLAND_DISPLAY") == "" {
t.Skip("Skipping integration test outside a Wayland session")
} }
tests := []struct { tests := []struct {
@@ -202,6 +203,13 @@ func TestClipboardBackend(t *testing.T) {
t.Logf("clipboard is available") t.Logf("clipboard is available")
} }
func TestClipboardPasteBackend(t *testing.T) {
backend := NewClipboardPasteBackend()
if backend.Name() != "clipboard-paste" {
t.Errorf("Name() = %s, want clipboard-paste", backend.Name())
}
}
// TestInjector_ClipboardMode tests clipboard-only injection // TestInjector_ClipboardMode tests clipboard-only injection
func TestInjector_ClipboardMode(t *testing.T) { func TestInjector_ClipboardMode(t *testing.T) {
config := Config{ config := Config{
+4
View File
@@ -85,9 +85,13 @@ func (y *ydotoolBackend) Inject(ctx context.Context, text string, timeout time.D
if err := y.Available(); err != nil { if err := y.Available(); err != nil {
return err return err
} }
socketPath := y.getSocketPath()
// ydotool type -- "text" // ydotool type -- "text"
cmd := exec.CommandContext(ctx, "ydotool", "type", "--", text) cmd := exec.CommandContext(ctx, "ydotool", "type", "--", text)
if socketPath != "" {
cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
}
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("ydotool failed: %w", err) return fmt.Errorf("ydotool failed: %w", err)
} }
+6
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
"github.com/sashabaranov/go-openai" "github.com/sashabaranov/go-openai"
@@ -17,6 +18,11 @@ type OpenAIAdapter struct {
// NewOpenAIAdapter creates a new OpenAI LLM adapter // NewOpenAIAdapter creates a new OpenAI LLM adapter
func NewOpenAIAdapter(cfg Config) *OpenAIAdapter { func NewOpenAIAdapter(cfg Config) *OpenAIAdapter {
if cfg.BaseURL != "" {
clientConfig := openai.DefaultConfig(cfg.APIKey)
clientConfig.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + "/v1"
return &OpenAIAdapter{client: openai.NewClientWithConfig(clientConfig), config: cfg}
}
return &OpenAIAdapter{ return &OpenAIAdapter{
client: openai.NewClient(cfg.APIKey), client: openai.NewClient(cfg.APIKey),
config: cfg, config: cfg,
+9
View File
@@ -21,6 +21,7 @@ type Config struct {
RemoveFillerWords bool RemoveFillerWords bool
CustomPrompt string CustomPrompt string
Keywords []string Keywords []string
BaseURL string
} }
// NewAdapter creates an LLM adapter based on the provider // NewAdapter creates an LLM adapter based on the provider
@@ -36,6 +37,14 @@ func NewAdapter(cfg Config) (Adapter, error) {
return nil, fmt.Errorf("Groq API key required") return nil, fmt.Errorf("Groq API key required")
} }
return NewGroqAdapter(cfg), nil return NewGroqAdapter(cfg), nil
case "llama-swap":
if cfg.APIKey == "" {
return nil, fmt.Errorf("LlamaSwap API key required")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("LlamaSwap base_url required")
}
return NewOpenAIAdapter(cfg), nil
default: default:
return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider) return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider)
} }
+1 -1
View File
@@ -317,6 +317,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
RemoveFillerWords: llmCfg.RemoveFillerWords, RemoveFillerWords: llmCfg.RemoveFillerWords,
CustomPrompt: llmCfg.CustomPrompt, CustomPrompt: llmCfg.CustomPrompt,
Keywords: llmCfg.Keywords, Keywords: llmCfg.Keywords,
BaseURL: llmCfg.BaseURL,
}) })
if err != nil { if err != nil {
log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err) log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err)
@@ -343,7 +344,6 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
} }
return r return r
}, textToInject) }, textToInject)
injector := p.injectorFactory(p.config.ToInjectionConfig()) injector := p.injectorFactory(p.config.ToInjectionConfig())
if err := injector.Inject(ctx, textToInject); err != nil { if err := injector.Inject(ctx, textToInject); err != nil {
+14
View File
@@ -0,0 +1,14 @@
package provider
// LlamaSwapProvider represents LlamaSwap's authenticated OpenAI-compatible
// proxy. The concrete model IDs are supplied in config because LlamaSwap can
// route to any model configured by its operator.
type LlamaSwapProvider struct{}
func (p *LlamaSwapProvider) Name() string { return ProviderLlamaSwap }
func (p *LlamaSwapProvider) RequiresAPIKey() bool { return true }
func (p *LlamaSwapProvider) ValidateAPIKey(key string) bool { return key != "" }
func (p *LlamaSwapProvider) APIKeyURL() string { return "" }
func (p *LlamaSwapProvider) IsLocal() bool { return false }
func (p *LlamaSwapProvider) Models() []Model { return nil }
func (p *LlamaSwapProvider) DefaultModel(ModelType) string { return "" }
+4
View File
@@ -8,6 +8,7 @@ const (
ProviderElevenLabs = "elevenlabs" ProviderElevenLabs = "elevenlabs"
ProviderDeepgram = "deepgram" ProviderDeepgram = "deepgram"
ProviderWhisperCpp = "whisper-cpp" ProviderWhisperCpp = "whisper-cpp"
ProviderLlamaSwap = "llama-swap"
) )
// Config provider names (used in config file transcription.provider) // Config provider names (used in config file transcription.provider)
@@ -27,6 +28,7 @@ const (
EnvMistralKey = "MISTRAL_API_KEY" EnvMistralKey = "MISTRAL_API_KEY"
EnvElevenLabsKey = "ELEVENLABS_API_KEY" EnvElevenLabsKey = "ELEVENLABS_API_KEY"
EnvDeepgramKey = "DEEPGRAM_API_KEY" EnvDeepgramKey = "DEEPGRAM_API_KEY"
EnvLlamaSwapKey = "LLAMA_SWAP_API_KEY"
) )
// Adapter type constants for transcription backends // Adapter type constants for transcription backends
@@ -66,6 +68,8 @@ func EnvVarForProvider(provider string) string {
return EnvElevenLabsKey return EnvElevenLabsKey
case ProviderDeepgram: case ProviderDeepgram:
return EnvDeepgramKey return EnvDeepgramKey
case ProviderLlamaSwap:
return EnvLlamaSwapKey
default: default:
return "" return ""
} }
+1
View File
@@ -31,6 +31,7 @@ func init() {
Register(&ElevenLabsProvider{}) Register(&ElevenLabsProvider{})
Register(&WhisperCppProvider{}) Register(&WhisperCppProvider{})
Register(&DeepgramProvider{}) Register(&DeepgramProvider{})
Register(&LlamaSwapProvider{})
} }
// Register adds a provider to the registry // Register adds a provider to the registry
+1 -1
View File
@@ -35,7 +35,7 @@ func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang str
if endpoint != nil && endpoint.BaseURL != "" { if endpoint != nil && endpoint.BaseURL != "" {
// use custom endpoint // use custom endpoint
clientConfig := openai.DefaultConfig(apiKey) clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = endpoint.BaseURL + "/v1" clientConfig.BaseURL = strings.TrimRight(endpoint.BaseURL, "/") + "/v1"
client = openai.NewClientWithConfig(clientConfig) client = openai.NewClientWithConfig(clientConfig)
} else { } else {
// default to OpenAI // default to OpenAI
+17
View File
@@ -33,6 +33,7 @@ type Config struct {
Keywords []string Keywords []string
Threads int // CPU threads for local transcription (0 = auto) Threads int // CPU threads for local transcription (0 = auto)
Streaming bool // use streaming mode if model supports it Streaming bool // use streaming mode if model supports it
BaseURL string // OpenAI-compatible base URL, without /v1
} }
// NewTranscriber creates a new transcriber based on model metadata // NewTranscriber creates a new transcriber based on model metadata
@@ -55,6 +56,22 @@ func NewTranscriber(config Config) (Transcriber, error) {
return nil, fmt.Errorf("%s API key required", cases.Title(language.English).String(registryProvider)) return nil, fmt.Errorf("%s API key required", cases.Title(language.English).String(registryProvider))
} }
// llama-swap proxies arbitrary OpenAI-compatible model IDs, so models are
// intentionally configured by the user rather than limited to this registry.
if registryProvider == provider.ProviderLlamaSwap {
if config.Model == "" {
return nil, fmt.Errorf("model is required for llama-swap")
}
if config.BaseURL == "" {
return nil, fmt.Errorf("llama-swap base_url required")
}
if config.Streaming {
return nil, fmt.Errorf("llama-swap transcription currently supports batch mode only (set streaming = false)")
}
endpoint := &provider.EndpointConfig{BaseURL: config.BaseURL}
return NewSimpleTranscriber(config, NewOpenAIAdapter(endpoint, config.APIKey, config.Model, config.Language, config.Keywords, registryProvider)), nil
}
// lookup model from provider // lookup model from provider
model, err := provider.GetModel(registryProvider, config.Model) model, err := provider.GetModel(registryProvider, config.Model)
if err != nil { if err != nil {