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:
parent
d01a874c17
commit
43862b7741
4 changed files with 57 additions and 13 deletions
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue