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
This commit is contained in:
rfshubert 2026-02-22 12:05:31 -03:00
parent d01a874c17
commit 43862b7741
4 changed files with 57 additions and 13 deletions

View file

@ -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",
)
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}
}