Compare commits
1
Commits
c89ff2b68e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4426af63cb |
+33
-2
@@ -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,15 +482,19 @@ 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"
|
||||||
|
ctrl_shift_v_classes = ["ghostty"] # Window-class substrings that paste with Ctrl+Shift+V
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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`**: Temporarily copies text to the regular clipboard, sends Ctrl+V through wtype, then restores the prior text clipboard. This works in browsers and Electron apps and is layout-independent; recommended for Dvorak/Colemak users. Requires `wl-clipboard` and `wtype`.
|
||||||
|
|
||||||
|
`ctrl_shift_v_classes` selects applications that need Ctrl+Shift+V instead of Ctrl+V. Matching is case-insensitive against Hyprland's active-window data, and the default `"ghostty"` handles Ghostty. Add a terminal's class substring to this list when needed.
|
||||||
- **`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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -106,9 +112,10 @@ func (c *Config) IsLLMEnabled() bool {
|
|||||||
|
|
||||||
func (c *Config) ToInjectionConfig() injection.Config {
|
func (c *Config) ToInjectionConfig() injection.Config {
|
||||||
return injection.Config{
|
return injection.Config{
|
||||||
Backends: c.Injection.Backends,
|
Backends: c.Injection.Backends,
|
||||||
YdotoolTimeout: c.Injection.YdotoolTimeout,
|
YdotoolTimeout: c.Injection.YdotoolTimeout,
|
||||||
WtypeTimeout: c.Injection.WtypeTimeout,
|
WtypeTimeout: c.Injection.WtypeTimeout,
|
||||||
ClipboardTimeout: c.Injection.ClipboardTimeout,
|
ClipboardTimeout: c.Injection.ClipboardTimeout,
|
||||||
|
CtrlShiftVClasses: c.Injection.CtrlShiftVClasses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ func DefaultConfig() *Config {
|
|||||||
Threads: 0,
|
Threads: 0,
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Backends: []string{"ydotool", "wtype", "clipboard"},
|
Backends: []string{"ydotool", "wtype", "clipboard"},
|
||||||
YdotoolTimeout: 5 * time.Second,
|
YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
|
CtrlShiftVClasses: []string{"ghostty"},
|
||||||
},
|
},
|
||||||
Notifications: NotificationsConfig{
|
Notifications: NotificationsConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|||||||
+21
-3
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,14 @@ func Save(cfg *Config) error {
|
|||||||
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
|
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
|
||||||
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
|
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
|
||||||
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
|
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
|
||||||
|
sb.WriteString(" ctrl_shift_v_classes = [")
|
||||||
|
for i, class := range cfg.Injection.CtrlShiftVClasses {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString(", ")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%q", class))
|
||||||
|
}
|
||||||
|
sb.WriteString("]\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
@@ -221,6 +232,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 +255,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 +267,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 +286,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 +336,16 @@ 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": Temporarily uses the regular clipboard + wtype Ctrl+V, then restores prior text clipboard.
|
||||||
|
# - ctrl_shift_v_classes: Window-class substrings that use Ctrl+Shift+V instead (default: ["ghostty"]).
|
||||||
# - "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).
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ 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
|
||||||
@@ -70,10 +71,11 @@ type TranscriptionConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type InjectionConfig struct {
|
type InjectionConfig struct {
|
||||||
Backends []string `toml:"backends"`
|
Backends []string `toml:"backends"`
|
||||||
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
|
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
|
||||||
WtypeTimeout time.Duration `toml:"wtype_timeout"`
|
WtypeTimeout time.Duration `toml:"wtype_timeout"`
|
||||||
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
|
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
|
||||||
|
CtrlShiftVClasses []string `toml:"ctrl_shift_v_classes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotificationsConfig struct {
|
type NotificationsConfig struct {
|
||||||
@@ -139,4 +141,5 @@ type LLMAdapterConfig struct {
|
|||||||
RemoveFillerWords bool
|
RemoveFillerWords bool
|
||||||
CustomPrompt string
|
CustomPrompt string
|
||||||
Keywords []string
|
Keywords []string
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-27
@@ -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,27 +85,40 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate model exists in provider
|
// LlamaSwap is an OpenAI-compatible router: its model IDs are defined by the
|
||||||
_, err := provider.GetModel(registryName, c.Transcription.Model)
|
// remote server, so they cannot be validated against Hyprvoice's static registry.
|
||||||
if err != nil {
|
if registryName == provider.ProviderLlamaSwap {
|
||||||
models := provider.ModelsOfType(p, provider.Transcription)
|
if c.Transcription.Streaming {
|
||||||
modelIDs := make([]string, len(models))
|
return fmt.Errorf("llama-swap transcription supports batch mode only (set transcription.streaming = false)")
|
||||||
for i, m := range models {
|
}
|
||||||
modelIDs[i] = m.ID
|
} 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)
|
// validate language-model compatibility using effective language (transcription overrides general)
|
||||||
effectiveLanguage := c.resolveEffectiveLanguage()
|
effectiveLanguage := c.resolveEffectiveLanguage()
|
||||||
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
|
||||||
@@ -121,20 +137,26 @@ 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, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate LLM model exists
|
if c.LLM.Provider == provider.ProviderLlamaSwap {
|
||||||
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
|
if err := validateLlamaSwapBaseURL(c.Providers[provider.ProviderLlamaSwap].BaseURL); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
models := provider.ModelsOfType(llmProvider, provider.LLM)
|
}
|
||||||
modelIDs := make([]string, len(models))
|
} else {
|
||||||
for i, m := range models {
|
// validate LLM model exists
|
||||||
modelIDs[i] = m.ID
|
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
|
// verify model is actually an LLM
|
||||||
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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package injection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clipboardPasteBackend temporarily puts text on the regular clipboard, then
|
||||||
|
// uses wtype to issue Ctrl+V. This works in applications that do not support
|
||||||
|
// the primary selection, such as browsers and Electron apps. The previous
|
||||||
|
// text clipboard is restored after the paste has been delivered.
|
||||||
|
type clipboardPasteBackend struct {
|
||||||
|
clipboard *clipboardBackend
|
||||||
|
wtype *wtypeBackend
|
||||||
|
ctrlShiftVClasses []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClipboardPasteBackend(ctrlShiftVClasses []string) Backend {
|
||||||
|
return &clipboardPasteBackend{
|
||||||
|
clipboard: NewClipboardBackend().(*clipboardBackend),
|
||||||
|
wtype: NewWtypeBackend().(*wtypeBackend),
|
||||||
|
ctrlShiftVClasses: ctrlShiftVClasses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.wtype.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
|
||||||
|
}
|
||||||
|
// wl-paste only needs to keep the clipboard owner alive while it reads the
|
||||||
|
// selection, so this command returns with the exact existing text content.
|
||||||
|
// --no-newline prevents wl-paste from adding a terminal-friendly newline.
|
||||||
|
previousClipboard, restore, err := c.readClipboard(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText := exec.CommandContext(ctx, "wl-copy")
|
||||||
|
copyText.Stdin = strings.NewReader(text)
|
||||||
|
if err := copyText.Run(); err != nil {
|
||||||
|
return fmt.Errorf("copy transcription to clipboard: %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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wtype resolves the keysym itself, avoiding ydotool's physical-keycode
|
||||||
|
// layout problem on Dvorak and Colemak setups. Terminal emulators typically
|
||||||
|
// reserve Ctrl+V, so configured window classes use Ctrl+Shift+V instead.
|
||||||
|
args := []string{"-M", "ctrl"}
|
||||||
|
if c.shouldUseCtrlShiftV(ctx) {
|
||||||
|
args = append(args, "-M", "shift", "-k", "v", "-m", "shift")
|
||||||
|
} else {
|
||||||
|
args = append(args, "-k", "v")
|
||||||
|
}
|
||||||
|
args = append(args, "-m", "ctrl")
|
||||||
|
cmd := exec.CommandContext(ctx, "wtype", args...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("paste clipboard with wtype: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !restore {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Receiving a Wayland paste is asynchronous. Leave the transcription
|
||||||
|
// available briefly, then put the user's prior clipboard back.
|
||||||
|
select {
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
restoreClipboard := exec.CommandContext(ctx, "wl-copy")
|
||||||
|
restoreClipboard.Stdin = strings.NewReader(previousClipboard)
|
||||||
|
if err := restoreClipboard.Run(); err != nil {
|
||||||
|
return fmt.Errorf("restore previous clipboard: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clipboardPasteBackend) shouldUseCtrlShiftV(ctx context.Context) bool {
|
||||||
|
if len(c.ctrlShiftVClasses) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, "hyprctl", "activewindow", "-j")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var activeWindow struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &activeWindow); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
class := strings.ToLower(activeWindow.Class)
|
||||||
|
for _, match := range c.ctrlShiftVClasses {
|
||||||
|
if match = strings.TrimSpace(strings.ToLower(match)); match != "" && strings.Contains(class, match) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *clipboardPasteBackend) readClipboard(ctx context.Context) (text string, restore bool, err error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// No clipboard owner is normal. There is simply nothing to restore.
|
||||||
|
if _, ok := err.(*exec.ExitError); ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("read existing clipboard: %w", err)
|
||||||
|
}
|
||||||
|
return string(output), true, nil
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@ 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
|
||||||
|
CtrlShiftVClasses []string // Hyprland window-class substrings that paste with Ctrl+Shift+V
|
||||||
}
|
}
|
||||||
|
|
||||||
type injector struct {
|
type injector struct {
|
||||||
@@ -34,6 +35,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(config.CtrlShiftVClasses))
|
||||||
default:
|
default:
|
||||||
log.Printf("Injection: unknown backend %q, skipping", name)
|
log.Printf("Injection: unknown backend %q, skipping", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]string{"ghostty"})
|
||||||
|
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{
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Re
|
|||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}, textToInject)
|
}, textToInject)
|
||||||
|
// Speech APIs occasionally include leading or trailing whitespace (for
|
||||||
|
// example Whisper commonly returns a leading space). It is transport
|
||||||
|
// formatting rather than dictated content, so never inject it.
|
||||||
|
textToInject = strings.TrimSpace(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 {
|
||||||
|
|||||||
@@ -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 "" }
|
||||||
@@ -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 ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ type Config struct {
|
|||||||
Language string
|
Language string
|
||||||
Model string
|
Model string
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user