feat: models test and fixes
This commit is contained in:
@@ -1966,10 +1966,12 @@ timeout = "5m"
|
||||
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
api_key = "test-key"
|
||||
model = "whisper-1"
|
||||
language = "es"
|
||||
|
||||
[providers.openai]
|
||||
api_key = "test-key"
|
||||
|
||||
[injection]
|
||||
backends = ["clipboard"]
|
||||
ydotool_timeout = "5s"
|
||||
|
||||
@@ -321,7 +321,7 @@ keywords = []
|
||||
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
|
||||
# - "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)
|
||||
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2, scribe_v2_realtime)
|
||||
#
|
||||
# LLM providers (for post-processing):
|
||||
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
|
||||
|
||||
@@ -28,7 +28,10 @@ var models = []ModelInfo{
|
||||
{ID: "base", Name: "Base", Filename: "ggml-base.bin", Size: "142MB", SizeBytes: 142_000_000, Multilingual: true},
|
||||
{ID: "small", Name: "Small", Filename: "ggml-small.bin", Size: "466MB", SizeBytes: 466_000_000, Multilingual: true},
|
||||
{ID: "medium", Name: "Medium", Filename: "ggml-medium.bin", Size: "1.5GB", SizeBytes: 1_500_000_000, Multilingual: true},
|
||||
{ID: "large-v1", Name: "Large V1", Filename: "ggml-large-v1.bin", Size: "2.9GB", SizeBytes: 2_900_000_000, Multilingual: true},
|
||||
{ID: "large-v2", Name: "Large V2", Filename: "ggml-large-v2.bin", Size: "2.9GB", SizeBytes: 2_900_000_000, Multilingual: true},
|
||||
{ID: "large-v3", Name: "Large V3", Filename: "ggml-large-v3.bin", Size: "3GB", SizeBytes: 3_000_000_000, Multilingual: true},
|
||||
{ID: "large-v3-turbo", Name: "Large V3 Turbo", Filename: "ggml-large-v3-turbo.bin", Size: "1.6GB", SizeBytes: 1_600_000_000, Multilingual: true},
|
||||
}
|
||||
|
||||
// modelByID maps model ID to ModelInfo for quick lookup
|
||||
|
||||
@@ -108,8 +108,8 @@ func TestGetModel(t *testing.T) {
|
||||
|
||||
func TestListModels(t *testing.T) {
|
||||
models := ListModels()
|
||||
if len(models) != 9 {
|
||||
t.Errorf("ListModels() returned %d models, want 9", len(models))
|
||||
if len(models) != 12 {
|
||||
t.Errorf("ListModels() returned %d models, want 12", len(models))
|
||||
}
|
||||
|
||||
// verify known models exist
|
||||
@@ -118,7 +118,7 @@ func TestListModels(t *testing.T) {
|
||||
ids[m.ID] = true
|
||||
}
|
||||
|
||||
expected := []string{"tiny.en", "base.en", "small.en", "medium.en", "tiny", "base", "small", "medium", "large-v3"}
|
||||
expected := []string{"tiny.en", "base.en", "small.en", "medium.en", "tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3", "large-v3-turbo"}
|
||||
for _, id := range expected {
|
||||
if !ids[id] {
|
||||
t.Errorf("ListModels() missing model %s", id)
|
||||
@@ -128,8 +128,8 @@ func TestListModels(t *testing.T) {
|
||||
|
||||
func TestListMultilingualModels(t *testing.T) {
|
||||
models := ListMultilingualModels()
|
||||
if len(models) != 5 {
|
||||
t.Errorf("ListMultilingualModels() returned %d models, want 5", len(models))
|
||||
if len(models) != 8 {
|
||||
t.Errorf("ListMultilingualModels() returned %d models, want 8", len(models))
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
|
||||
@@ -16,6 +16,10 @@ func (p *DeepgramProvider) ValidateAPIKey(key string) bool {
|
||||
return len(key) > 0
|
||||
}
|
||||
|
||||
func (p *DeepgramProvider) APIKeyURL() string {
|
||||
return "https://console.deepgram.com/project/keys"
|
||||
}
|
||||
|
||||
func (p *DeepgramProvider) IsLocal() bool {
|
||||
return false
|
||||
}
|
||||
@@ -23,7 +27,6 @@ func (p *DeepgramProvider) IsLocal() bool {
|
||||
func (p *DeepgramProvider) Models() []Model {
|
||||
// https://developers.deepgram.com/docs/models-languages-overview
|
||||
nova3Langs := deepgramNova3Languages
|
||||
// https://developers.deepgram.com/docs/models-languages-overview
|
||||
nova2Langs := deepgramNova2Languages
|
||||
|
||||
docsURL := "https://developers.deepgram.com/docs/language"
|
||||
@@ -32,7 +35,7 @@ func (p *DeepgramProvider) Models() []Model {
|
||||
{
|
||||
ID: "nova-3",
|
||||
Name: "Nova-3",
|
||||
Description: "Best accuracy, 40+ languages",
|
||||
Description: "Best accuracy; streaming available for faster response",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: true,
|
||||
@@ -46,7 +49,7 @@ func (p *DeepgramProvider) Models() []Model {
|
||||
{
|
||||
ID: "nova-2",
|
||||
Name: "Nova-2",
|
||||
Description: "Fast, 30+ languages, filler words",
|
||||
Description: "Cheaper legacy model; still solid accuracy",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: true,
|
||||
|
||||
@@ -29,11 +29,8 @@ func TestDeepgramProvider_Models(t *testing.T) {
|
||||
t.Errorf("Models() returned %d models, want 2", len(models))
|
||||
}
|
||||
|
||||
// all models should support both batch and streaming
|
||||
// all models should support both streaming and batch
|
||||
for _, m := range models {
|
||||
if !m.SupportsBatch {
|
||||
t.Errorf("model %s should support batch", m.ID)
|
||||
}
|
||||
if !m.SupportsStreaming {
|
||||
t.Errorf("model %s should support streaming", m.ID)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ func (p *ElevenLabsProvider) ValidateAPIKey(key string) bool {
|
||||
return len(key) > 0
|
||||
}
|
||||
|
||||
func (p *ElevenLabsProvider) APIKeyURL() string {
|
||||
return "https://elevenlabs.io/app/settings/api-keys"
|
||||
}
|
||||
|
||||
func (p *ElevenLabsProvider) IsLocal() bool {
|
||||
return false
|
||||
}
|
||||
@@ -29,40 +33,46 @@ func (p *ElevenLabsProvider) Models() []Model {
|
||||
{
|
||||
ID: "scribe_v1",
|
||||
Name: "Scribe v1",
|
||||
Description: "90+ languages, best accuracy",
|
||||
Description: "Most accurate; best for precision-critical work",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterElevenLabs,
|
||||
StreamingAdapter: AdapterElevenLabsStream,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
|
||||
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
|
||||
DocsURL: docsURL,
|
||||
},
|
||||
{
|
||||
ID: "scribe_v2",
|
||||
Name: "Scribe v2",
|
||||
Description: "Lower latency batch transcription",
|
||||
Description: "Faster processing with good accuracy",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterElevenLabs,
|
||||
StreamingAdapter: AdapterElevenLabsStream,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
|
||||
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
|
||||
DocsURL: docsURL,
|
||||
},
|
||||
{
|
||||
ID: "scribe_v2_realtime",
|
||||
Name: "Scribe v2 Realtime",
|
||||
Description: "Real-time streaming, <150ms latency",
|
||||
Description: "Instant words as you speak; faster but costs more",
|
||||
Type: Transcription,
|
||||
SupportsBatch: false,
|
||||
SupportsStreaming: true,
|
||||
Local: false,
|
||||
AdapterType: AdapterElevenLabsStream,
|
||||
AdapterType: AdapterElevenLabs,
|
||||
StreamingAdapter: AdapterElevenLabsStream,
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
|
||||
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
|
||||
DocsURL: docsURL,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ func (p *GroqProvider) ValidateAPIKey(key string) bool {
|
||||
return strings.HasPrefix(key, "gsk_")
|
||||
}
|
||||
|
||||
func (p *GroqProvider) APIKeyURL() string {
|
||||
return "https://console.groq.com/keys"
|
||||
}
|
||||
|
||||
func (p *GroqProvider) IsLocal() bool {
|
||||
return false
|
||||
}
|
||||
@@ -31,7 +35,7 @@ func (p *GroqProvider) Models() []Model {
|
||||
{
|
||||
ID: "whisper-large-v3",
|
||||
Name: "Whisper Large v3",
|
||||
Description: "Full Whisper v3 model, best accuracy",
|
||||
Description: "Best accuracy; generous free tier makes this great default",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -44,7 +48,7 @@ func (p *GroqProvider) Models() []Model {
|
||||
{
|
||||
ID: "whisper-large-v3-turbo",
|
||||
Name: "Whisper Large v3 Turbo",
|
||||
Description: "Faster Whisper v3 with slightly lower accuracy",
|
||||
Description: "Faster with slight accuracy tradeoff; still very good",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -58,7 +62,7 @@ func (p *GroqProvider) Models() []Model {
|
||||
{
|
||||
ID: "llama-3.3-70b-versatile",
|
||||
Name: "Llama 3.3 70B Versatile",
|
||||
Description: "Most capable Llama model",
|
||||
Description: "Best quality cleanup; smart rewrites, free tier available",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -69,18 +73,7 @@ func (p *GroqProvider) Models() []Model {
|
||||
{
|
||||
ID: "llama-3.1-8b-instant",
|
||||
Name: "Llama 3.1 8B Instant",
|
||||
Description: "Fast and efficient",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
|
||||
},
|
||||
{
|
||||
ID: "mixtral-8x7b-32768",
|
||||
Name: "Mixtral 8x7B",
|
||||
Description: "Mixture of experts model",
|
||||
Description: "Very fast; good for simple cleanup tasks",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
|
||||
@@ -34,6 +34,8 @@ var deepgramNova2Languages = []string{
|
||||
"ru", "sk", "es", "es-419", "sv", "sv-SE", "th", "th-TH", "tr", "uk", "vi",
|
||||
}
|
||||
|
||||
var deepgramFluxLanguages = []string{"en"}
|
||||
|
||||
var elevenLabsTranscriptionLanguages = []string{
|
||||
"bel", "bos", "bul", "cat", "hrv", "ces", "dan", "nld", "eng", "est", "fin", "fra",
|
||||
"glg", "deu", "ell", "hun", "isl", "ind", "ita", "jpn", "kan", "lav", "mkd", "msa",
|
||||
|
||||
@@ -16,6 +16,10 @@ func (p *MistralProvider) ValidateAPIKey(key string) bool {
|
||||
return len(key) > 0
|
||||
}
|
||||
|
||||
func (p *MistralProvider) APIKeyURL() string {
|
||||
return "https://admin.mistral.ai/organization/api-keys"
|
||||
}
|
||||
|
||||
func (p *MistralProvider) IsLocal() bool {
|
||||
return false
|
||||
}
|
||||
@@ -29,27 +33,12 @@ func (p *MistralProvider) Models() []Model {
|
||||
{
|
||||
ID: "voxtral-mini-latest",
|
||||
Name: "Voxtral Mini Latest",
|
||||
Description: "Latest Voxtral model, best for most uses",
|
||||
Description: "EU-hosted; good for data residency or Mistral ecosystem",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: true,
|
||||
SupportsStreaming: false,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
StreamingAdapter: "mistral-streaming", // not yet implemented
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"},
|
||||
DocsURL: docsURL,
|
||||
},
|
||||
{
|
||||
ID: "voxtral-mini-2507",
|
||||
Name: "Voxtral Mini 2507",
|
||||
Description: "Stable Voxtral version from July 2025",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: true,
|
||||
Local: false,
|
||||
AdapterType: AdapterOpenAI,
|
||||
StreamingAdapter: "mistral-streaming", // not yet implemented
|
||||
SupportedLanguages: allLangs,
|
||||
Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"},
|
||||
DocsURL: docsURL,
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestModel_IsStreaming(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "streaming-only model",
|
||||
model: Model{ID: "scribe_v2_realtime", SupportsBatch: false, SupportsStreaming: true},
|
||||
model: Model{ID: "flux-general-en", SupportsBatch: false, SupportsStreaming: true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
@@ -89,7 +89,7 @@ func TestModel_SupportsBothModes(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "streaming-only model",
|
||||
model: Model{ID: "scribe_v2_realtime", SupportsBatch: false, SupportsStreaming: true},
|
||||
model: Model{ID: "flux-general-en", SupportsBatch: false, SupportsStreaming: true},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
@@ -325,7 +325,7 @@ func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) {
|
||||
"mistral": "https://docs.mistral.ai/capabilities/audio/",
|
||||
"elevenlabs": "https://elevenlabs.io/speech-to-text",
|
||||
"deepgram": "https://developers.deepgram.com/docs/language",
|
||||
"whisper-cpp": "https://github.com/openai/whisper#available-models-and-languages",
|
||||
"whisper-cpp": "https://github.com/ggml-org/whisper.cpp#models",
|
||||
}
|
||||
|
||||
for _, pName := range providers {
|
||||
|
||||
@@ -17,6 +17,10 @@ func (p *OpenAIProvider) ValidateAPIKey(key string) bool {
|
||||
return strings.HasPrefix(key, "sk-")
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) APIKeyURL() string {
|
||||
return "https://platform.openai.com/api-keys"
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) IsLocal() bool {
|
||||
return false
|
||||
}
|
||||
@@ -32,7 +36,7 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
{
|
||||
ID: "whisper-1",
|
||||
Name: "Whisper 1",
|
||||
Description: "OpenAI's production speech-to-text model",
|
||||
Description: "Reliable and cost-effective; good default for most use cases",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -45,7 +49,7 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
{
|
||||
ID: "gpt-4o-transcribe",
|
||||
Name: "GPT-4o Transcribe",
|
||||
Description: "High quality transcription with GPT-4o",
|
||||
Description: "Top accuracy; slower and pricier but best quality",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -58,7 +62,7 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
{
|
||||
ID: "gpt-4o-mini-transcribe",
|
||||
Name: "GPT-4o Mini Transcribe",
|
||||
Description: "Fast transcription with GPT-4o Mini",
|
||||
Description: "Good balance of speed, cost, and quality",
|
||||
Type: Transcription,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -71,7 +75,7 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
{
|
||||
ID: "gpt-4o-realtime-preview",
|
||||
Name: "GPT-4o Realtime Preview",
|
||||
Description: "Real-time streaming transcription with GPT-4o",
|
||||
Description: "Instant words as you speak; fastest but most expensive",
|
||||
Type: Transcription,
|
||||
SupportsBatch: false,
|
||||
SupportsStreaming: true,
|
||||
@@ -85,7 +89,7 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
{
|
||||
ID: "gpt-4o-mini",
|
||||
Name: "GPT-4o Mini",
|
||||
Description: "Fast and affordable GPT-4 variant",
|
||||
Description: "Fast and cheap; good default for text cleanup",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
@@ -96,7 +100,7 @@ func (p *OpenAIProvider) Models() []Model {
|
||||
{
|
||||
ID: "gpt-4o",
|
||||
Name: "GPT-4o",
|
||||
Description: "Most capable GPT-4 model",
|
||||
Description: "Best quality cleanup; pricier but smarter rewrites",
|
||||
Type: LLM,
|
||||
SupportsBatch: true,
|
||||
SupportsStreaming: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ type Provider interface {
|
||||
Name() string
|
||||
RequiresAPIKey() bool
|
||||
ValidateAPIKey(key string) bool
|
||||
APIKeyURL() string
|
||||
IsLocal() bool
|
||||
Models() []Model
|
||||
DefaultModel(t ModelType) string
|
||||
|
||||
@@ -279,7 +279,7 @@ func TestValidateModelLanguage_ErrorFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
// should contain docs URL
|
||||
if !strings.Contains(errMsg, "https://github.com/openai/whisper") {
|
||||
if !strings.Contains(errMsg, "https://github.com/ggml-org/whisper.cpp") {
|
||||
t.Errorf("error should contain docs URL, got: %s", errMsg)
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ func TestElevenLabsProvider(t *testing.T) {
|
||||
t.Errorf("ElevenLabsProvider.Models() = %d models, want 3", len(models))
|
||||
}
|
||||
|
||||
// Check batch-only models
|
||||
// Check batch + streaming models
|
||||
scribeV1, err := GetModel("elevenlabs", "scribe_v1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModel('elevenlabs', 'scribe_v1') error: %v", err)
|
||||
@@ -357,6 +357,9 @@ func TestElevenLabsProvider(t *testing.T) {
|
||||
if scribeV1.AdapterType != "elevenlabs" {
|
||||
t.Errorf("scribe_v1 AdapterType=%q, want 'elevenlabs'", scribeV1.AdapterType)
|
||||
}
|
||||
if scribeV1.StreamingAdapter != "elevenlabs-streaming" {
|
||||
t.Errorf("scribe_v1 StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV1.StreamingAdapter)
|
||||
}
|
||||
|
||||
scribeV2, err := GetModel("elevenlabs", "scribe_v2")
|
||||
if err != nil {
|
||||
@@ -371,8 +374,10 @@ func TestElevenLabsProvider(t *testing.T) {
|
||||
if scribeV2.AdapterType != "elevenlabs" {
|
||||
t.Errorf("scribe_v2 AdapterType=%q, want 'elevenlabs'", scribeV2.AdapterType)
|
||||
}
|
||||
if scribeV2.StreamingAdapter != "elevenlabs-streaming" {
|
||||
t.Errorf("scribe_v2 StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV2.StreamingAdapter)
|
||||
}
|
||||
|
||||
// Check streaming-only model
|
||||
scribeV2Realtime, err := GetModel("elevenlabs", "scribe_v2_realtime")
|
||||
if err != nil {
|
||||
t.Fatalf("GetModel('elevenlabs', 'scribe_v2_realtime') error: %v", err)
|
||||
@@ -383,8 +388,11 @@ func TestElevenLabsProvider(t *testing.T) {
|
||||
if !scribeV2Realtime.SupportsStreaming {
|
||||
t.Error("scribe_v2_realtime should have SupportsStreaming=true")
|
||||
}
|
||||
if scribeV2Realtime.AdapterType != "elevenlabs-streaming" {
|
||||
t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs-streaming'", scribeV2Realtime.AdapterType)
|
||||
if scribeV2Realtime.AdapterType != "elevenlabs" {
|
||||
t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs'", scribeV2Realtime.AdapterType)
|
||||
}
|
||||
if scribeV2Realtime.StreamingAdapter != "elevenlabs-streaming" {
|
||||
t.Errorf("scribe_v2_realtime StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV2Realtime.StreamingAdapter)
|
||||
}
|
||||
|
||||
// All models should share the same supported language list
|
||||
|
||||
@@ -17,16 +17,20 @@ func (p *WhisperCppProvider) ValidateAPIKey(key string) bool {
|
||||
return true // no API key needed
|
||||
}
|
||||
|
||||
func (p *WhisperCppProvider) APIKeyURL() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *WhisperCppProvider) IsLocal() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *WhisperCppProvider) Models() []Model {
|
||||
// https://github.com/openai/whisper#available-models-and-languages
|
||||
// https://github.com/ggml-org/whisper.cpp#models
|
||||
allLangs := whisperTranscriptionLanguages
|
||||
// https://github.com/openai/whisper#available-models-and-languages
|
||||
// https://github.com/ggml-org/whisper.cpp#models
|
||||
englishOnly := whisperEnglishOnlyLanguages
|
||||
docsURL := "https://github.com/openai/whisper#available-models-and-languages"
|
||||
docsURL := "https://github.com/ggml-org/whisper.cpp#models"
|
||||
|
||||
whisperModels := whisper.ListModels()
|
||||
result := make([]Model, 0, len(whisperModels))
|
||||
@@ -63,10 +67,36 @@ func (p *WhisperCppProvider) Models() []Model {
|
||||
}
|
||||
|
||||
func modelDescription(m whisper.ModelInfo) string {
|
||||
if m.Multilingual {
|
||||
return "Multilingual local transcription"
|
||||
switch m.ID {
|
||||
case "tiny.en":
|
||||
return "Free/offline; fastest but low accuracy, good for weak hardware"
|
||||
case "base.en":
|
||||
return "Free/offline; balanced speed and accuracy, recommended start"
|
||||
case "small.en":
|
||||
return "Free/offline; better accuracy, needs decent CPU"
|
||||
case "medium.en":
|
||||
return "Free/offline; best .en accuracy, needs good CPU/RAM"
|
||||
case "tiny":
|
||||
return "Free/offline multilingual; fastest but low accuracy"
|
||||
case "base":
|
||||
return "Free/offline multilingual; balanced, recommended start"
|
||||
case "small":
|
||||
return "Free/offline multilingual; better accuracy, needs decent CPU"
|
||||
case "medium":
|
||||
return "Free/offline multilingual; great accuracy, needs good CPU/RAM"
|
||||
case "large-v1":
|
||||
return "Free/offline; high accuracy, needs strong CPU/GPU"
|
||||
case "large-v2":
|
||||
return "Free/offline; high accuracy, needs strong CPU/GPU"
|
||||
case "large-v3":
|
||||
return "Free/offline; best accuracy available, needs strong hardware"
|
||||
case "large-v3-turbo":
|
||||
return "Free/offline; near-best accuracy with better speed"
|
||||
}
|
||||
return "English-only local transcription (faster)"
|
||||
if m.Multilingual {
|
||||
return "Free/offline multilingual model"
|
||||
}
|
||||
return "Free/offline English model"
|
||||
}
|
||||
|
||||
func (p *WhisperCppProvider) DefaultModel(t ModelType) string {
|
||||
|
||||
@@ -16,9 +16,9 @@ func TestWhisperCppProvider_Models(t *testing.T) {
|
||||
p := &WhisperCppProvider{}
|
||||
models := p.Models()
|
||||
|
||||
// verify we have 9 models
|
||||
if len(models) != 9 {
|
||||
t.Errorf("expected 9 models, got %d", len(models))
|
||||
// verify we have 12 models
|
||||
if len(models) != 12 {
|
||||
t.Errorf("expected 12 models, got %d", len(models))
|
||||
}
|
||||
|
||||
// verify all models have required fields
|
||||
@@ -77,11 +77,14 @@ func TestWhisperCppProvider_MultilingualModels(t *testing.T) {
|
||||
models := p.Models()
|
||||
|
||||
multilingualIDs := map[string]bool{
|
||||
"tiny": true,
|
||||
"base": true,
|
||||
"small": true,
|
||||
"medium": true,
|
||||
"large-v3": true,
|
||||
"tiny": true,
|
||||
"base": true,
|
||||
"small": true,
|
||||
"medium": true,
|
||||
"large-v1": true,
|
||||
"large-v2": true,
|
||||
"large-v3": true,
|
||||
"large-v3-turbo": true,
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
|
||||
@@ -36,6 +36,7 @@ type DeepgramAdapter struct {
|
||||
|
||||
// finalization signaling
|
||||
finalizeDone chan struct{}
|
||||
finalizing bool // true when Finalize() has been called
|
||||
}
|
||||
|
||||
// deepgramCloseStream message to signal end of audio
|
||||
@@ -235,7 +236,8 @@ func (a *DeepgramAdapter) buildURL() (string, error) {
|
||||
q.Set("language", lang)
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
// nova-3 uses "keyterm" (singular), others use "keywords" (plural)
|
||||
if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") {
|
||||
q.Set("keywords", strings.Join(a.keywords, ","))
|
||||
}
|
||||
|
||||
@@ -277,6 +279,20 @@ func (a *DeepgramAdapter) readLoop() {
|
||||
default:
|
||||
}
|
||||
|
||||
// check if we're finalizing - normal close after finalize is expected
|
||||
a.mu.Lock()
|
||||
finalizing := a.finalizing
|
||||
a.mu.Unlock()
|
||||
|
||||
if finalizing {
|
||||
// expected close after finalization, signal done and exit gracefully
|
||||
select {
|
||||
case a.finalizeDone <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// attempt reconnection
|
||||
log.Printf("deepgram: read error: %v, attempting reconnection", err)
|
||||
if !a.reconnect() {
|
||||
@@ -411,6 +427,11 @@ func (a *DeepgramAdapter) Finalize(ctx context.Context) error {
|
||||
default:
|
||||
}
|
||||
|
||||
// mark as finalizing to prevent reconnection attempts on normal close
|
||||
a.mu.Lock()
|
||||
a.finalizing = true
|
||||
a.mu.Unlock()
|
||||
|
||||
// send CloseStream message
|
||||
msg := deepgramCloseStream{Type: "CloseStream"}
|
||||
|
||||
@@ -447,6 +468,9 @@ func (a *DeepgramAdapter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mark as finalizing to prevent reconnection attempts
|
||||
a.finalizing = true
|
||||
|
||||
// cancel context first to signal reader to stop
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
|
||||
@@ -49,21 +49,31 @@ func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, l
|
||||
|
||||
// Transcribe sends audio data to Deepgram's pre-recorded API
|
||||
func (a *DeepgramBatchAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
|
||||
if len(audioData) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// convert raw PCM to WAV format
|
||||
wavData, err := convertToWAV(audioData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("convert to WAV: %w", err)
|
||||
}
|
||||
|
||||
// build URL with query parameters
|
||||
apiURL, err := a.buildURL()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build url: %w", err)
|
||||
}
|
||||
|
||||
// create request with audio data as body
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(audioData))
|
||||
// create request with WAV data as body
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(wavData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
// set headers
|
||||
req.Header.Set("Authorization", "Token "+a.apiKey)
|
||||
req.Header.Set("Content-Type", "audio/wav") // we send raw PCM wrapped as WAV
|
||||
req.Header.Set("Content-Type", "audio/wav")
|
||||
|
||||
// send request
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
@@ -123,7 +133,8 @@ func (a *DeepgramBatchAdapter) buildURL() (string, error) {
|
||||
q.Set("language", lang)
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
// nova-3 uses "keyterm" (singular), others use "keywords" (plural)
|
||||
if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") {
|
||||
q.Set("keywords", strings.Join(a.keywords, ","))
|
||||
}
|
||||
|
||||
|
||||
@@ -82,13 +82,12 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s
|
||||
}
|
||||
}
|
||||
|
||||
if len(a.keywords) > 0 {
|
||||
keytermsJSON, err := json.Marshal(a.keywords)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal keyterms: %w", err)
|
||||
}
|
||||
if err := writer.WriteField("keyterms", string(keytermsJSON)); err != nil {
|
||||
return "", fmt.Errorf("write keyterms: %w", err)
|
||||
// keyterms only supported on scribe_v2, not scribe_v1
|
||||
if a.model != "scribe_v1" {
|
||||
for _, keyword := range a.keywords {
|
||||
if err := writer.WriteField("keyterms", keyword); err != nil {
|
||||
return "", fmt.Errorf("write keyterms: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -242,6 +243,9 @@ func (a *ElevenLabsStreamingAdapter) readLoop() {
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if a.handleFatalClose(err) {
|
||||
return
|
||||
}
|
||||
// check if context was cancelled (normal shutdown)
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
@@ -291,21 +295,92 @@ func (a *ElevenLabsStreamingAdapter) readLoop() {
|
||||
case "error", "auth_error", "quota_exceeded", "rate_limited",
|
||||
"queue_overflow", "resource_exhausted", "session_time_limit_exceeded",
|
||||
"input_error", "chunk_size_exceeded", "insufficient_audio_activity",
|
||||
"transcriber_error", "commit_throttled", "unaccepted_terms":
|
||||
"transcriber_error", "commit_throttled", "unaccepted_terms", "invalid_request":
|
||||
// error message
|
||||
errMsg := msg.Error
|
||||
if errMsg == "" {
|
||||
errMsg = msg.MessageType
|
||||
}
|
||||
log.Printf("elevenlabs-streaming: error: %s", errMsg)
|
||||
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("elevenlabs: %s", errMsg)}
|
||||
err := fmt.Errorf("elevenlabs: %s", errMsg)
|
||||
if isElevenLabsFatalMessageType(msg.MessageType) {
|
||||
a.handleFatalError(err)
|
||||
return
|
||||
}
|
||||
a.emitResultError(err)
|
||||
|
||||
default:
|
||||
log.Printf("elevenlabs-streaming: unknown message type: %s", msg.MessageType)
|
||||
log.Printf("elevenlabs-streaming: unknown message type: %s payload=%s", msg.MessageType, strings.TrimSpace(string(message)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ElevenLabsStreamingAdapter) emitResultError(err error) {
|
||||
select {
|
||||
case a.resultsCh <- TranscriptionResult{Error: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ElevenLabsStreamingAdapter) handleFatalError(err error) {
|
||||
fatalErr := NewFatalTranscriptionError(err)
|
||||
log.Printf("elevenlabs-streaming: fatal error: %v", err)
|
||||
a.emitResultError(fatalErr)
|
||||
a.closeConn()
|
||||
if a.cancel != nil {
|
||||
a.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ElevenLabsStreamingAdapter) handleFatalClose(err error) bool {
|
||||
var closeErr *websocket.CloseError
|
||||
if !errors.As(err, &closeErr) {
|
||||
return false
|
||||
}
|
||||
if !isElevenLabsFatalCloseCode(closeErr.Code) {
|
||||
return false
|
||||
}
|
||||
reason := strings.TrimSpace(closeErr.Text)
|
||||
if reason == "" {
|
||||
reason = "no reason provided"
|
||||
}
|
||||
a.handleFatalError(fmt.Errorf("elevenlabs websocket closed (%d): %s", closeErr.Code, reason))
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *ElevenLabsStreamingAdapter) closeConn() {
|
||||
a.mu.Lock()
|
||||
conn := a.conn
|
||||
a.conn = nil
|
||||
a.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func isElevenLabsFatalCloseCode(code int) bool {
|
||||
switch code {
|
||||
case websocket.ClosePolicyViolation,
|
||||
websocket.CloseUnsupportedData,
|
||||
websocket.CloseInvalidFramePayloadData,
|
||||
websocket.CloseMessageTooBig,
|
||||
websocket.CloseProtocolError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isElevenLabsFatalMessageType(messageType string) bool {
|
||||
switch messageType {
|
||||
case "auth_error", "unaccepted_terms", "invalid_request", "input_error", "chunk_size_exceeded":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// SendChunk sends audio data to the WebSocket
|
||||
func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error {
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package transcriber
|
||||
|
||||
import "errors"
|
||||
|
||||
// FatalTranscriptionError marks an error as non-recoverable for the current session.
|
||||
type FatalTranscriptionError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FatalTranscriptionError) Error() string {
|
||||
if e == nil || e.Err == nil {
|
||||
return "fatal transcription error"
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *FatalTranscriptionError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func NewFatalTranscriptionError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &FatalTranscriptionError{Err: err}
|
||||
}
|
||||
|
||||
func IsFatalTranscriptionError(err error) bool {
|
||||
var fatal *FatalTranscriptionError
|
||||
return errors.As(err, &fatal)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package transcriber
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,6 +20,7 @@ type StreamingTranscriber struct {
|
||||
// accumulated final text
|
||||
finalText strings.Builder
|
||||
mu sync.Mutex
|
||||
fatalErr error
|
||||
|
||||
// coordination
|
||||
ctx context.Context
|
||||
@@ -66,6 +68,24 @@ func (t *StreamingTranscriber) sendAudio(frameCh <-chan recording.AudioFrame, er
|
||||
return
|
||||
}
|
||||
if err := t.adapter.SendChunk(frame.Data); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
if t.ctx.Err() == nil && t.cancel != nil {
|
||||
t.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
if IsFatalTranscriptionError(err) {
|
||||
if t.setFatalErr(err) {
|
||||
select {
|
||||
case errCh <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if t.cancel != nil {
|
||||
t.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case errCh <- err:
|
||||
default:
|
||||
@@ -98,6 +118,19 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
|
||||
|
||||
func (t *StreamingTranscriber) processResult(result TranscriptionResult, errCh chan<- error) {
|
||||
if result.Error != nil {
|
||||
if IsFatalTranscriptionError(result.Error) {
|
||||
if t.setFatalErr(result.Error) {
|
||||
select {
|
||||
case errCh <- result.Error:
|
||||
default:
|
||||
}
|
||||
}
|
||||
log.Printf("streaming transcriber: result error: %v", result.Error)
|
||||
if t.cancel != nil {
|
||||
t.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case errCh <- result.Error:
|
||||
default:
|
||||
@@ -154,11 +187,37 @@ func (t *StreamingTranscriber) Stop(ctx context.Context) error {
|
||||
t.wg.Wait()
|
||||
|
||||
// close the adapter
|
||||
return t.adapter.Close()
|
||||
closeErr := t.adapter.Close()
|
||||
if fatalErr := t.getFatalErr(); fatalErr != nil {
|
||||
return fatalErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (t *StreamingTranscriber) GetFinalTranscription() (string, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.fatalErr != nil {
|
||||
return "", t.fatalErr
|
||||
}
|
||||
return t.finalText.String(), nil
|
||||
}
|
||||
|
||||
func (t *StreamingTranscriber) setFatalErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.fatalErr != nil {
|
||||
return false
|
||||
}
|
||||
t.fatalErr = err
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *StreamingTranscriber) getFatalErr() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.fatalErr
|
||||
}
|
||||
|
||||
@@ -83,14 +83,16 @@ func NewTranscriber(config Config) (Transcriber, error) {
|
||||
config.Language = ""
|
||||
}
|
||||
|
||||
// determine if we should use streaming mode
|
||||
useStreaming := config.Streaming && model.SupportsStreaming
|
||||
|
||||
// fail if streaming-only model is used without streaming enabled
|
||||
if !useStreaming && !model.SupportsBatch {
|
||||
// validate streaming/batch mode compatibility
|
||||
if config.Streaming && !model.SupportsStreaming {
|
||||
return nil, fmt.Errorf("model %s does not support streaming mode", model.ID)
|
||||
}
|
||||
if !config.Streaming && !model.SupportsBatch {
|
||||
return nil, fmt.Errorf("model %s requires streaming mode (set streaming = true in config)", model.ID)
|
||||
}
|
||||
|
||||
useStreaming := config.Streaming
|
||||
|
||||
// streaming mode: use StreamingTranscriber
|
||||
if useStreaming {
|
||||
// pick the right adapter type for streaming
|
||||
|
||||
@@ -145,6 +145,17 @@ func TestNewTranscriber(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "elevenlabs batch model with streaming enabled fails",
|
||||
config: Config{
|
||||
Provider: "elevenlabs",
|
||||
APIKey: "test-key",
|
||||
Language: "en",
|
||||
Model: "scribe_v2",
|
||||
Streaming: true,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "deepgram streaming model creates StreamingTranscriber",
|
||||
config: Config{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
|
||||
// test elevenlabs - has batch-only and streaming-only models
|
||||
// test elevenlabs - includes batch+streaming and streaming-only models
|
||||
options := getTranscriptionModelOptions("elevenlabs")
|
||||
|
||||
// should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime
|
||||
@@ -23,12 +23,7 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
if model.SupportsStreaming && !model.SupportsBatch {
|
||||
// streaming-only should mention streaming
|
||||
if !strings.Contains(opt.Desc, "streaming") {
|
||||
t.Errorf("streaming-only model %s should mention streaming in desc: %s", opt.ID, opt.Desc)
|
||||
}
|
||||
} else if model.SupportsBothModes() {
|
||||
if model.SupportsBothModes() {
|
||||
// both modes should mention batch+streaming
|
||||
if !strings.Contains(opt.Desc, "batch+streaming") {
|
||||
t.Errorf("both-modes model %s should mention batch+streaming in desc: %s", opt.ID, opt.Desc)
|
||||
@@ -75,11 +70,12 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
|
||||
func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
|
||||
options := getTranscriptionModelOptions("deepgram")
|
||||
|
||||
// Deepgram has 2 models: nova-3, nova-2 - both support batch+streaming
|
||||
// Deepgram has 2 models: nova-3, nova-2
|
||||
if len(options) != 2 {
|
||||
t.Errorf("expected 2 options for deepgram, got %d", len(options))
|
||||
}
|
||||
|
||||
// all deepgram models support both modes
|
||||
for _, opt := range options {
|
||||
if !strings.Contains(opt.Desc, "batch+streaming") {
|
||||
t.Errorf("deepgram model %s should mention batch+streaming: %s", opt.ID, opt.Desc)
|
||||
|
||||
+10
-4
@@ -66,13 +66,13 @@ func onboardingSummaryScreen(state *wizardState, onBack func() screen) screen {
|
||||
|
||||
func newMenuScreen(state *wizardState) screen {
|
||||
items := []optionItem{
|
||||
{title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders},
|
||||
{title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave},
|
||||
{title: formatVoiceModelLabel(state.cfg), desc: "Pick the transcription provider, model, and language.", value: menuVoiceModel},
|
||||
{title: formatLLMLabel(state.cfg), desc: "Configure post-processing and custom prompts.", value: menuLLM},
|
||||
{title: formatKeywordsLabel(state.cfg), desc: "Words to preserve spelling and phrasing.", value: menuKeywords},
|
||||
{title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders},
|
||||
{title: formatNotificationsLabel(state.cfg), desc: "Notification type and message text.", value: menuNotifications},
|
||||
{title: "Advanced Settings", desc: "Recording, injection, and timeout settings.", value: menuAdvanced},
|
||||
{title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave},
|
||||
{title: "Discard & Exit", desc: "Exit without saving changes.", value: menuDiscard},
|
||||
}
|
||||
|
||||
@@ -178,6 +178,9 @@ func newAPIKeyInputScreen(state *wizardState, providerName string, onContinue fu
|
||||
}
|
||||
}
|
||||
desc := []string{fmt.Sprintf("Enter your %s API key", displayName)}
|
||||
if url := getProviderKeyURL(providerName); url != "" {
|
||||
desc = append(desc, fmt.Sprintf("Get key: %s", url))
|
||||
}
|
||||
validate := func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("API key is required")
|
||||
@@ -354,8 +357,11 @@ func newLanguageScreen(state *wizardState, model *provider.Model, onBack func()
|
||||
|
||||
func applyStreamingSelection(state *wizardState, model *provider.Model, onBack func() screen, next func() screen) screen {
|
||||
if model.SupportsBothModes() {
|
||||
desc := []string{"This model supports both batch and streaming modes."}
|
||||
return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Lower latency, higher resource use.", "No, batch", "Wait for full transcription.", func() screen {
|
||||
desc := []string{
|
||||
"This model supports both batch and streaming modes.",
|
||||
"Streaming is quicker but more expensive.",
|
||||
}
|
||||
return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Quicker response, higher cost.", "No, batch", "Wait for full transcription (cheaper).", func() screen {
|
||||
state.cfg.Transcription.Streaming = true
|
||||
return next()
|
||||
}, func() screen {
|
||||
|
||||
@@ -33,6 +33,14 @@ func getProviderDisplayName(providerName string) string {
|
||||
return providerName
|
||||
}
|
||||
|
||||
func getProviderKeyURL(providerName string) string {
|
||||
p := provider.GetProvider(providerName)
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.APIKeyURL()
|
||||
}
|
||||
|
||||
func maskAPIKey(key string) string {
|
||||
if len(key) <= 8 {
|
||||
return "***"
|
||||
|
||||
@@ -15,6 +15,10 @@ func TestWizardMenuTransitionAppliesSize(t *testing.T) {
|
||||
updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
model = updated.(wizardModel)
|
||||
|
||||
// move down to "Voice Model" item (index 1) which leads to a listScreen
|
||||
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = updated.(wizardModel)
|
||||
|
||||
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(wizardModel)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user