feat: generalize STT to support any OpenAI-compatible Whisper endpoint

Extract Transcriber interface and rename GroqTranscriber to
OpenAICompatTranscriber. Add stt_model field to agents.defaults
for explicit STT model selection via model_list.

3-tier backward-compatible resolution:
1. agents.defaults.stt_model → model_list lookup
2. providers.groq.api_key (legacy)
3. groq/ prefix in model_list (legacy)

Also attach transcriber to OneBot channel (was missing).
This commit is contained in:
rfshubert 2026-02-22 08:41:59 -03:00
parent cb0c8703fb
commit c05ac2e8cc
11 changed files with 465 additions and 36 deletions

View file

@ -10,7 +10,6 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/agent"
@ -25,7 +24,6 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
func gatewayCmd() {
@ -121,38 +119,35 @@ func gatewayCmd() {
// Inject channel manager into agent loop for command handling
agentLoop.SetChannelManager(channelManager)
var transcriber *voice.GroqTranscriber
groqAPIKey := cfg.Providers.Groq.APIKey
if groqAPIKey == "" {
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
groqAPIKey = mc.APIKey
break
}
}
}
if groqAPIKey != "" {
transcriber = voice.NewGroqTranscriber(groqAPIKey)
logger.InfoC("voice", "Groq voice transcription enabled")
// STT (Speech-to-Text) transcriber setup
transcriber := resolveSTTTranscriber(cfg)
if transcriber != nil {
logger.InfoC("voice", "STT voice transcription enabled")
}
if transcriber != nil {
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
logger.InfoC("voice", "STT transcription attached to Telegram channel")
}
}
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Discord channel")
logger.InfoC("voice", "STT transcription attached to Discord channel")
}
}
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel")
logger.InfoC("voice", "STT transcription attached to Slack channel")
}
}
if onebotChannel, ok := channelManager.GetChannel("onebot"); ok {
if oc, ok := onebotChannel.(*channels.OneBotChannel); ok {
oc.SetTranscriber(transcriber)
logger.InfoC("voice", "STT transcription attached to OneBot channel")
}
}
}
@ -246,3 +241,14 @@ func setupCronTool(
return cronService
}
func getDefaultSTTBase(protocol string) string {
switch protocol {
case "openai":
return "https://api.openai.com/v1"
case "groq":
return "https://api.groq.com/openai/v1"
default:
return ""
}
}

View file

@ -0,0 +1,51 @@
package main
import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/voice"
)
// resolveSTTTranscriber resolves the STT transcriber using a 3-tier fallback strategy:
// 1. agents.defaults.stt_model → model_list lookup
// 2. providers.groq.api_key (backward compat)
// 3. groq/ prefix in model_list (backward compat)
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 != "" {
protocol, modelID := providers.ExtractProtocol(mc.Model)
apiBase := mc.APIBase
if apiBase == "" {
apiBase = getDefaultSTTBase(protocol)
}
return voice.NewOpenAICompatTranscriber(mc.APIKey, apiBase, modelID)
}
}
}
// 2. Backward compat: providers.groq.api_key
if cfg.Providers.Groq.APIKey != "" {
return voice.NewOpenAICompatTranscriber(
cfg.Providers.Groq.APIKey,
"https://api.groq.com/openai/v1",
"whisper-large-v3",
)
}
// 3. Backward compat: groq/ in model_list (no stt_model set)
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
return voice.NewOpenAICompatTranscriber(
mc.APIKey,
"https://api.groq.com/openai/v1",
"whisper-large-v3",
)
}
}
return nil
}

View file

@ -0,0 +1,241 @@
package main
import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/voice"
)
func TestGetDefaultSTTBase(t *testing.T) {
tests := []struct {
protocol string
expected string
}{
{"openai", "https://api.openai.com/v1"},
{"groq", "https://api.groq.com/openai/v1"},
{"unknown", ""},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.protocol, func(t *testing.T) {
if got := getDefaultSTTBase(tt.protocol); got != tt.expected {
t.Errorf("getDefaultSTTBase(%q) = %q, want %q", tt.protocol, got, tt.expected)
}
})
}
}
func TestResolveSTTTranscriber_STTModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
STTModel: "whisper",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "whisper",
Model: "openai/whisper-1",
APIKey: "sk-test",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber, got nil")
}
if !tr.IsAvailable() {
t.Error("expected transcriber to be available")
}
}
func TestResolveSTTTranscriber_STTModelWithAPIBase(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
STTModel: "whisper",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "whisper",
Model: "openai/whisper-1",
APIKey: "sk-test",
APIBase: "https://custom.api.com/v1",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber, got nil")
}
// Verify it's the right type and has the right fields
oat, ok := tr.(*voice.OpenAICompatTranscriber)
if !ok {
t.Fatal("expected *voice.OpenAICompatTranscriber")
}
_ = oat // Can't access unexported fields from test, but the resolution worked
}
func TestResolveSTTTranscriber_STTModelGroq(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
STTModel: "whisper",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "whisper",
Model: "groq/whisper-large-v3",
APIKey: "gsk-test",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber, got nil")
}
}
func TestResolveSTTTranscriber_BackwardCompatGroqProvider(t *testing.T) {
cfg := &config.Config{
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{
APIKey: "gsk-test-key",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber, got nil")
}
if !tr.IsAvailable() {
t.Error("expected transcriber to be available")
}
}
func TestResolveSTTTranscriber_BackwardCompatGroqModelList(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{
ModelName: "groq-llama",
Model: "groq/llama-3.3-70b",
APIKey: "gsk-test-key",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber, got nil")
}
}
func TestResolveSTTTranscriber_NoneAvailable(t *testing.T) {
cfg := &config.Config{}
tr := resolveSTTTranscriber(cfg)
if tr != nil {
t.Error("expected nil transcriber when no config available")
}
}
func TestResolveSTTTranscriber_STTModelPriority(t *testing.T) {
// stt_model should take priority over providers.groq.api_key
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
STTModel: "whisper",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "whisper",
Model: "openai/whisper-1",
APIKey: "sk-openai-key",
},
},
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{
APIKey: "gsk-groq-key",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber, got nil")
}
// The transcriber should be from stt_model (OpenAI), not from Groq
// We can verify by checking it's available
if !tr.IsAvailable() {
t.Error("expected transcriber to be available")
}
}
func TestResolveSTTTranscriber_STTModelNotInModelList(t *testing.T) {
// stt_model set but not found in model_list, should fall back
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
STTModel: "nonexistent",
},
},
Providers: config.ProvidersConfig{
Groq: config.ProviderConfig{
APIKey: "gsk-fallback",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr == nil {
t.Fatal("expected transcriber from fallback, got nil")
}
}
func TestResolveSTTTranscriber_LLMEntryNotMatchedAsSTT(t *testing.T) {
// A non-groq LLM entry should NOT be matched as STT provider
cfg := &config.Config{
ModelList: []config.ModelConfig{
{
ModelName: "gpt4",
Model: "openai/gpt-4o",
APIKey: "sk-test",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr != nil {
t.Error("expected nil - LLM entries should not match as STT providers")
}
}
func TestResolveSTTTranscriber_STTModelNoAPIKey(t *testing.T) {
// stt_model found but no API key, should fall back
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
STTModel: "whisper",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "whisper",
Model: "openai/whisper-1",
APIKey: "",
},
},
}
tr := resolveSTTTranscriber(cfg)
if tr != nil {
t.Error("expected nil - model has no API key")
}
}

View file

@ -4,6 +4,7 @@
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model": "gpt4",
"stt_model": "",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@ -43,6 +44,12 @@
"model": "openai/gpt-5.2",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "whisper",
"model": "groq/whisper-large-v3",
"api_key": "gsk_xxx",
"_comment": "STT model for voice transcription. Set agents.defaults.stt_model to 'whisper' to use."
}
],
"channels": {

View file

@ -26,7 +26,7 @@ type DiscordChannel struct {
*BaseChannel
session *discordgo.Session
config config.DiscordConfig
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
ctx context.Context
typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID → stop signal
@ -51,7 +51,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
}, nil
}
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *DiscordChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber
}

View file

@ -35,7 +35,7 @@ type OneBotChannel struct {
selfID int64
pending map[string]chan json.RawMessage
pendingMu sync.Mutex
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
lastMessageID sync.Map
pendingEmojiMsg sync.Map
}
@ -111,7 +111,7 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
}, nil
}
func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *OneBotChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber
}

View file

@ -26,7 +26,7 @@ type SlackChannel struct {
socketClient *socketmode.Client
botUserID string
teamID string
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
ctx context.Context
cancel context.CancelFunc
pendingAcks sync.Map
@ -59,7 +59,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
}, nil
}
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *SlackChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber
}

View file

@ -29,7 +29,7 @@ type TelegramChannel struct {
commands TelegramCommander
config *config.Config
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
placeholders sync.Map // chatID -> messageID
stopThinking sync.Map // chatID -> thinkingCancel
}
@ -86,7 +86,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}, nil
}
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *TelegramChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber
}

View file

@ -177,6 +177,7 @@ type AgentDefaults struct {
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
STTModel string `json:"stt_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STT_MODEL"`
}
type ChannelsConfig struct {

View file

@ -16,9 +16,15 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
type GroqTranscriber struct {
type Transcriber interface {
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
IsAvailable() bool
}
type OpenAICompatTranscriber struct {
apiKey string
apiBase string
model string
httpClient *http.Client
}
@ -28,20 +34,20 @@ type TranscriptionResponse struct {
Duration float64 `json:"duration,omitempty"`
}
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
func NewOpenAICompatTranscriber(apiKey, apiBase, model string) *OpenAICompatTranscriber {
logger.DebugCF("voice", "Creating STT transcriber", map[string]any{"has_api_key": apiKey != ""})
apiBase := "https://api.groq.com/openai/v1"
return &GroqTranscriber{
return &OpenAICompatTranscriber{
apiKey: apiKey,
apiBase: apiBase,
model: model,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
func (t *OpenAICompatTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
audioFile, err := os.Open(audioFilePath)
@ -79,7 +85,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
if err = writer.WriteField("model", t.model); err != nil {
logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
@ -104,7 +110,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+t.apiKey)
logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{
logger.DebugCF("voice", "Sending transcription request to STT API", map[string]any{
"url": url,
"request_size_bytes": requestBody.Len(),
"file_size_bytes": fileInfo.Size(),
@ -131,7 +137,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
logger.DebugCF("voice", "Received response from Groq API", map[string]any{
logger.DebugCF("voice", "Received response from STT API", map[string]any{
"status_code": resp.StatusCode,
"response_size_bytes": len(body),
})
@ -152,7 +158,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
return &result, nil
}
func (t *GroqTranscriber) IsAvailable() bool {
func (t *OpenAICompatTranscriber) IsAvailable() bool {
available := t.apiKey != ""
logger.DebugCF("voice", "Checking transcriber availability", map[string]any{"available": available})
return available

View file

@ -0,0 +1,117 @@
package voice
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestNewOpenAICompatTranscriber(t *testing.T) {
tr := NewOpenAICompatTranscriber("test-key", "https://api.example.com/v1", "whisper-1")
if tr.apiKey != "test-key" {
t.Errorf("expected apiKey 'test-key', got %q", tr.apiKey)
}
if tr.apiBase != "https://api.example.com/v1" {
t.Errorf("expected apiBase 'https://api.example.com/v1', got %q", tr.apiBase)
}
if tr.model != "whisper-1" {
t.Errorf("expected model 'whisper-1', got %q", tr.model)
}
}
func TestOpenAICompatTranscriber_IsAvailable(t *testing.T) {
tests := []struct {
name string
apiKey string
expected bool
}{
{"with key", "test-key", true},
{"empty key", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tr := NewOpenAICompatTranscriber(tt.apiKey, "https://example.com", "model")
if got := tr.IsAvailable(); got != tt.expected {
t.Errorf("IsAvailable() = %v, want %v", got, tt.expected)
}
})
}
}
func TestOpenAICompatTranscriber_ImplementsInterface(t *testing.T) {
var _ Transcriber = (*OpenAICompatTranscriber)(nil)
}
func TestOpenAICompatTranscriber_Transcribe(t *testing.T) {
// Create a mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify it hits the right endpoint
if r.URL.Path != "/audio/transcriptions" {
t.Errorf("expected path /audio/transcriptions, got %s", r.URL.Path)
}
// Verify auth header
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("unexpected auth header: %s", r.Header.Get("Authorization"))
}
// Verify it's multipart
if err := r.ParseMultipartForm(10 << 20); err != nil {
t.Errorf("failed to parse multipart form: %v", err)
}
// Verify model field
if model := r.FormValue("model"); model != "whisper-1" {
t.Errorf("expected model 'whisper-1', got %q", model)
}
// Return mock response
resp := TranscriptionResponse{
Text: "Hello world",
Language: "en",
Duration: 1.5,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
tr := NewOpenAICompatTranscriber("test-key", server.URL, "whisper-1")
// Create a temp audio file
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 != "Hello world" {
t.Errorf("expected text 'Hello world', got %q", result.Text)
}
if result.Language != "en" {
t.Errorf("expected language 'en', got %q", result.Language)
}
}
func TestOpenAICompatTranscriber_TranscribeError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error": "invalid api key"}`))
}))
defer server.Close()
tr := NewOpenAICompatTranscriber("bad-key", server.URL, "whisper-1")
tmpDir := t.TempDir()
audioFile := filepath.Join(tmpDir, "test.ogg")
os.WriteFile(audioFile, []byte("fake audio data"), 0644)
_, err := tr.Transcribe(context.Background(), audioFile)
if err == nil {
t.Fatal("expected error, got nil")
}
}