From 43862b774195cb86f0d777d4c41d13ed31d9c14c Mon Sep 17 00:00:00 2001 From: rfshubert Date: Sun, 22 Feb 2026 12:05:31 -0300 Subject: [PATCH] fix: support keyless STT endpoints and harden transcriber - Only send Authorization header when apiKey is non-empty - Base IsAvailable on apiBase+model instead of apiKey - Allow stt_model entries without api_key for self-hosted Whisper - Normalize trailing slash on apiBase to prevent double-slash URLs - Use providers.GetDefaultAPIBase for Groq fallback URLs - Check os.WriteFile error in transcriber test --- cmd/picoclaw/stt_resolution.go | 6 ++-- cmd/picoclaw/stt_resolution_test.go | 6 ++-- pkg/voice/transcriber.go | 9 ++++-- pkg/voice/transcriber_test.go | 49 ++++++++++++++++++++++++++--- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/cmd/picoclaw/stt_resolution.go b/cmd/picoclaw/stt_resolution.go index 6663e7d14..459f45d6d 100644 --- a/cmd/picoclaw/stt_resolution.go +++ b/cmd/picoclaw/stt_resolution.go @@ -16,7 +16,7 @@ func resolveSTTTranscriber(cfg *config.Config) voice.Transcriber { // 1. Resolve from agents.defaults.stt_model → model_list lookup if cfg.Agents.Defaults.STTModel != "" { for _, mc := range cfg.ModelList { - if mc.ModelName == cfg.Agents.Defaults.STTModel && mc.APIKey != "" { + if mc.ModelName == cfg.Agents.Defaults.STTModel { protocol, modelID := providers.ExtractProtocol(mc.Model) apiBase := mc.APIBase if apiBase == "" { @@ -34,7 +34,7 @@ func resolveSTTTranscriber(cfg *config.Config) voice.Transcriber { if cfg.Providers.Groq.APIKey != "" { return voice.NewOpenAICompatTranscriber( cfg.Providers.Groq.APIKey, - "https://api.groq.com/openai/v1", + providers.GetDefaultAPIBase("groq"), "whisper-large-v3", ) } @@ -44,7 +44,7 @@ func resolveSTTTranscriber(cfg *config.Config) voice.Transcriber { if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { return voice.NewOpenAICompatTranscriber( mc.APIKey, - "https://api.groq.com/openai/v1", + providers.GetDefaultAPIBase("groq"), "whisper-large-v3", ) } diff --git a/cmd/picoclaw/stt_resolution_test.go b/cmd/picoclaw/stt_resolution_test.go index 59a1bb32d..075079175 100644 --- a/cmd/picoclaw/stt_resolution_test.go +++ b/cmd/picoclaw/stt_resolution_test.go @@ -224,7 +224,7 @@ func TestResolveSTTTranscriber_LLMEntryNotMatchedAsSTT(t *testing.T) { } func TestResolveSTTTranscriber_STTModelNoAPIKey(t *testing.T) { - // stt_model found but no API key, should fall back + // stt_model found with no API key (keyless/self-hosted endpoint) - should still resolve cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ @@ -241,7 +241,7 @@ func TestResolveSTTTranscriber_STTModelNoAPIKey(t *testing.T) { } tr := resolveSTTTranscriber(cfg) - if tr != nil { - t.Error("expected nil - model has no API key") + if tr == nil { + t.Error("expected non-nil transcriber - keyless endpoints with known protocol should resolve") } } diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index 13c923017..168de1534 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/logger" @@ -39,7 +40,7 @@ func NewOpenAICompatTranscriber(apiKey, apiBase, model string) *OpenAICompatTran return &OpenAICompatTranscriber{ apiKey: apiKey, - apiBase: apiBase, + apiBase: strings.TrimRight(apiBase, "/"), model: model, httpClient: &http.Client{ Timeout: 60 * time.Second, @@ -108,7 +109,9 @@ func (t *OpenAICompatTranscriber) Transcribe(ctx context.Context, audioFilePath } req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+t.apiKey) + if t.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+t.apiKey) + } logger.DebugCF("voice", "Sending transcription request to STT API", map[string]any{ "url": url, @@ -159,7 +162,7 @@ func (t *OpenAICompatTranscriber) Transcribe(ctx context.Context, audioFilePath } func (t *OpenAICompatTranscriber) IsAvailable() bool { - available := t.apiKey != "" + available := t.apiBase != "" && t.model != "" logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available}) return available } diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go index 973f25d05..761912e20 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -27,14 +27,18 @@ func TestOpenAICompatTranscriber_IsAvailable(t *testing.T) { tests := []struct { name string apiKey string + apiBase string + model string expected bool }{ - {"with key", "test-key", true}, - {"empty key", "", false}, + {"with key and base", "test-key", "https://example.com", "model", true}, + {"keyless with base", "", "https://example.com", "model", true}, + {"empty base", "test-key", "", "model", false}, + {"empty model", "test-key", "https://example.com", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tr := NewOpenAICompatTranscriber(tt.apiKey, "https://example.com", "model") + tr := NewOpenAICompatTranscriber(tt.apiKey, tt.apiBase, tt.model) if got := tr.IsAvailable(); got != tt.expected { t.Errorf("IsAvailable() = %v, want %v", got, tt.expected) } @@ -108,10 +112,47 @@ func TestOpenAICompatTranscriber_TranscribeError(t *testing.T) { tmpDir := t.TempDir() audioFile := filepath.Join(tmpDir, "test.ogg") - os.WriteFile(audioFile, []byte("fake audio data"), 0644) + if err := os.WriteFile(audioFile, []byte("fake audio data"), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } _, err := tr.Transcribe(context.Background(), audioFile) if err == nil { t.Fatal("expected error, got nil") } } + +func TestNewOpenAICompatTranscriber_NormalizesTrailingSlash(t *testing.T) { + tr := NewOpenAICompatTranscriber("key", "https://api.example.com/v1/", "whisper-1") + if tr.apiBase != "https://api.example.com/v1" { + t.Errorf("expected trailing slash trimmed, got %q", tr.apiBase) + } +} + +func TestOpenAICompatTranscriber_KeylessTranscription(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "" { + t.Errorf("expected no Authorization header for keyless, got %q", auth) + } + resp := TranscriptionResponse{Text: "keyless works"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + tr := NewOpenAICompatTranscriber("", server.URL, "whisper-1") + + tmpDir := t.TempDir() + audioFile := filepath.Join(tmpDir, "test.ogg") + if err := os.WriteFile(audioFile, []byte("fake audio data"), 0644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + result, err := tr.Transcribe(context.Background(), audioFile) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if result.Text != "keyless works" { + t.Errorf("expected 'keyless works', got %q", result.Text) + } +}