diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 28ef76ad3..c138f837e 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -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 "" + } +} diff --git a/cmd/picoclaw/stt_resolution.go b/cmd/picoclaw/stt_resolution.go new file mode 100644 index 000000000..cc70d71b5 --- /dev/null +++ b/cmd/picoclaw/stt_resolution.go @@ -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 +} diff --git a/cmd/picoclaw/stt_resolution_test.go b/cmd/picoclaw/stt_resolution_test.go new file mode 100644 index 000000000..3f3f16858 --- /dev/null +++ b/cmd/picoclaw/stt_resolution_test.go @@ -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") + } +} diff --git a/config/config.example.json b/config/config.example.json index 77a8c0683..0b4ef7a24 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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": { diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 20f3b267c..64983be20 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -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 } diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index cee8ad9d3..ef33631ae 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -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 } diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index f7359cd6d..1272f82a6 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -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 } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index a0a1c8d0a..c5ef91173 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -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 } diff --git a/pkg/config/config.go b/pkg/config/config.go index 20556011a..ae1efb37c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 { diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index f973e77fe..13c923017 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -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 diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go new file mode 100644 index 000000000..973f25d05 --- /dev/null +++ b/pkg/voice/transcriber_test.go @@ -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") + } +}