-**Option 2 : Installation APK (bientôt disponible)**
+**Option 2 : Installation APK**
-Un APK Android autonome avec WebUI intégré est en développement. Restez à l'écoute !
+Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
-**Opsi 2: Instal APK (segera hadir)**
+**Opsi 2: Instal APK**
-APK Android mandiri dengan WebUI bawaan sedang dalam pengembangan. Pantau terus!
+Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
-**Opzione 2: APK Install (prossimamente)**
+**Opzione 2: Installazione APK**
-Un APK Android standalone con WebUI integrato è in sviluppo. Resta sintonizzato!
+Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
-**オプション 2: APK インストール(近日公開)**
+**オプション 2: APK インストール**
-内蔵 WebUI を備えたスタンドアロン Android APK を開発中です。お楽しみに!
+[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
-**Option 2: APK Install (coming soon)**
+**Option 2: APK Install**
-A standalone Android APK with built-in WebUI is in development. Stay tuned!
+Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
-**Pilihan 2: APK (akan datang)**
+**Pilihan 2: Pasang APK**
-APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan!
+Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
-**Opção 2: Instalação via APK (em breve)**
+**Opção 2: Instalação via APK**
-Um APK Android independente com WebUI integrado está em desenvolvimento. Fique ligado!
+Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
-**Tùy chọn 2: Cài đặt APK (sắp ra mắt)**
+**Tùy chọn 2: Cài đặt APK**
-Một APK Android độc lập với WebUI tích hợp đang được phát triển. Hãy đón chờ!
+Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
-**方式二:APK 安装(即将推出)**
+**方式二:APK 安装**
-内置 WebUI 的独立 Android APK 正在开发中,敬请期待!
+从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
%s", escaped))
@@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string {
return text
}
+type linkMatch struct {
+ text string
+ links [][2]string // [label, url]
+}
+
+func extractLinks(text string) linkMatch {
+ matches := reLink.FindAllStringSubmatch(text, -1)
+
+ extracted := make([][2]string, 0, len(matches))
+ for _, match := range matches {
+ extracted = append(extracted, [2]string{match[1], match[2]})
+ }
+
+ i := 0
+ text = reLink.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00LK%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return linkMatch{text: text, links: extracted}
+}
+
type codeBlockMatch struct {
text string
codes []string
diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go
new file mode 100644
index 000000000..7754ee076
--- /dev/null
+++ b/pkg/channels/telegram/parser_markdown_to_html_test.go
@@ -0,0 +1,66 @@
+package telegram
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_markdownToTelegramHTML(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "plain text",
+ input: "hello world",
+ expected: "hello world",
+ },
+ {
+ name: "bold",
+ input: "**bold text**",
+ expected: "bold text",
+ },
+ {
+ name: "italic",
+ input: "_italic text_",
+ expected: "italic text",
+ },
+ {
+ name: "link without underscores in URL",
+ input: "[click here](https://example.com/path)",
+ expected: `click here`,
+ },
+ {
+ name: "link with underscores in URL is not corrupted by italic regex",
+ // Google Flights URLs use URL-safe base64 with underscores in the tfs param.
+ // Previously reItalic ran after reLink, matching _text_ inside href and injecting
+ // tags into the URL, which broke the link in Telegram.
+ input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)",
+ expected: `3 → 10 сентября — от $202`,
+ },
+ {
+ name: "multiple links all survive",
+ input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
+ expected: `first and second`,
+ },
+ {
+ name: "link label with HTML special chars is escaped",
+ input: "[a & b](https://example.com)",
+ expected: `a & b`,
+ },
+ {
+ name: "HTML special chars in plain text are escaped",
+ input: "a & b < c > d",
+ expected: "a & b < c > d",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ actual := markdownToTelegramHTML(tc.input)
+ require.Equal(t, tc.expected, actual)
+ })
+ }
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index f64a8f79b..ccb394a57 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -168,26 +168,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
+ var messageIDs []string
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
@@ -206,16 +207,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
}
if smallerLen <= 0 {
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
replyToID = ""
continue
}
@@ -244,21 +247,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue
}
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
}
- return nil
+ return messageIDs, nil
}
type sendChunkParams struct {
@@ -275,7 +280,7 @@ type sendChunkParams struct {
func (c *TelegramChannel) sendChunk(
ctx context.Context,
params sendChunkParams,
-) error {
+) (string, error) {
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
tgMsg.MessageThreadID = params.threadID
if params.useMarkdownV2 {
@@ -292,17 +297,19 @@ func (c *TelegramChannel) sendChunk(
}
}
- if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
+ pMsg, err := c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
logParseFailed(err, params.useMarkdownV2)
tgMsg.Text = params.mdFallback
tgMsg.ParseMode = ""
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
+ pMsg, err = c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
+ return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
- return nil
+ return strconv.Itoa(pMsg.MessageID), nil
}
// maxTypingDuration limits how long the typing indicator can run.
@@ -420,21 +427,22 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
}
// SendMedia implements the channels.MediaSender interface.
-func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
store := c.GetMediaStore()
if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
+ var messageIDs []string
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -454,6 +462,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
continue
}
+ var tgResult *telego.Message
switch part.Type {
case "image":
params := &telego.SendPhotoParams{
@@ -462,11 +471,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Photo: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendPhoto(ctx, params)
+ tgResult, err = c.bot.SendPhoto(ctx, params)
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
file.Close()
- return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
}
docParams := &telego.SendDocumentParams{
@@ -475,7 +484,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, docParams)
+ tgResult, err = c.bot.SendDocument(ctx, docParams)
}
case "audio":
// Send OGG files with "voice" in the filename as Telegram voice
@@ -488,7 +497,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Voice: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVoice(ctx, vparams)
+ tgResult, err = c.bot.SendVoice(ctx, vparams)
} else {
params := &telego.SendAudioParams{
ChatID: tu.ID(chatID),
@@ -496,7 +505,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Audio: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendAudio(ctx, params)
+ tgResult, err = c.bot.SendAudio(ctx, params)
}
case "video":
params := &telego.SendVideoParams{
@@ -505,7 +514,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Video: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVideo(ctx, params)
+ tgResult, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types
params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID),
@@ -513,9 +522,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, params)
+ tgResult, err = c.bot.SendDocument(ctx, params)
}
+ if tgResult != nil {
+ messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID))
+ }
file.Close()
if err != nil {
@@ -523,11 +535,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
"type": part.Type,
"error": err.Error(),
})
- return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
}
}
- return nil
+ return messageIDs, nil
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
@@ -660,6 +672,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
+ if message.ReplyToMessage != nil {
+ quotedMedia := quotedTelegramMediaRefs(
+ message.ReplyToMessage,
+ func(fileID, ext, filename string) string {
+ localPath := c.downloadFile(ctx, fileID, ext)
+ if localPath == "" {
+ return ""
+ }
+ return storeMedia(localPath, filename)
+ },
+ )
+ if len(quotedMedia) > 0 {
+ mediaPaths = append(quotedMedia, mediaPaths...)
+ }
+ content = c.prependTelegramQuotedReply(content, message.ReplyToMessage)
+ }
+
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
@@ -693,6 +722,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
+ if message.ReplyToMessage != nil {
+ metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID)
+ }
// Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 {
@@ -713,6 +745,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
+func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
+ quoted := strings.TrimSpace(telegramQuotedContent(reply))
+ if quoted == "" {
+ return content
+ }
+
+ author := telegramQuotedAuthor(reply)
+ role := c.telegramQuotedRole(reply)
+ if strings.TrimSpace(content) == "" {
+ return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted)
+ }
+ return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content)
+}
+
+func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string {
+ if message == nil {
+ return "unknown"
+ }
+
+ if message.From != nil {
+ if !message.From.IsBot {
+ return "user"
+ }
+ if c.isOwnBotUser(message.From) {
+ return "assistant"
+ }
+ return "bot"
+ }
+
+ if message.SenderChat != nil {
+ return "chat"
+ }
+
+ return "unknown"
+}
+
+func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool {
+ if c == nil || c.bot == nil || user == nil || !user.IsBot {
+ return false
+ }
+
+ if botID := c.bot.ID(); botID != 0 && user.ID == botID {
+ return true
+ }
+
+ botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@")
+ if botUsername == "" {
+ return false
+ }
+ return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername)
+}
+
+func telegramQuotedAuthor(message *telego.Message) string {
+ if message == nil || message.From == nil {
+ return "unknown"
+ }
+ if username := strings.TrimSpace(message.From.Username); username != "" {
+ return username
+ }
+ if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" {
+ return firstName
+ }
+ return "unknown"
+}
+
+func telegramQuotedContent(message *telego.Message) string {
+ if message == nil {
+ return ""
+ }
+
+ var parts []string
+ if text := strings.TrimSpace(message.Text); text != "" {
+ parts = append(parts, text)
+ }
+ if caption := strings.TrimSpace(message.Caption); caption != "" {
+ parts = append(parts, caption)
+ }
+ switch {
+ case len(message.Photo) > 0:
+ parts = append(parts, "[image: photo]")
+ }
+ switch {
+ case message.Voice != nil:
+ parts = append(parts, "[voice]")
+ case message.Audio != nil:
+ parts = append(parts, "[audio]")
+ }
+ if message.Document != nil {
+ parts = append(parts, "[file]")
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func quotedTelegramMediaRefs(
+ message *telego.Message,
+ resolve func(fileID, ext, filename string) string,
+) []string {
+ if message == nil || resolve == nil {
+ return nil
+ }
+
+ var refs []string
+ if message.Voice != nil {
+ if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ if message.Audio != nil {
+ if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ return refs
+}
+
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
@@ -985,3 +1133,8 @@ func cryptoRandInt() int {
_, _ = rand.Read(b[:])
return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
}
+
+// VoiceCapabilities returns the voice capabilities of the channel.
+func (c *TelegramChannel) VoiceCapabilities() channels.VoiceCapabilities {
+ return channels.VoiceCapabilities{ASR: true, TTS: true}
+}
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index fd189d9a7..4f7a2600b 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
@@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
+func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
+ t.Helper()
+ b, err := json.Marshal(user)
+ require.NoError(t, err)
+ return &ta.Response{Ok: true, Result: b}
+}
+
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
@@ -168,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
)
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -206,7 +214,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -231,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
})
@@ -248,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
@@ -271,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) {
longContent := strings.Repeat("a", 4000)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -294,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
@@ -312,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -334,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
longContent := strings.Repeat("x", 4001)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -364,7 +372,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
@@ -399,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) {
// Ensure the test content matches the intended boundary conditions.
assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test")
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "123456",
Content: content,
})
@@ -435,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) {
ch := newTestChannel(t, caller)
ch.SetRunning(false)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -453,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
@@ -510,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
@@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
+func assertHandleMessageQuotedUserReply(
+ t *testing.T,
+ chatID int64,
+ messageID int,
+ userID int64,
+ userName string,
+ userText string,
+ replyMessageID int,
+ replyText string,
+ replyCaption string,
+ replyAuthorID int64,
+ replyAuthorName string,
+ expectedContent string,
+) {
+ t.Helper()
+
+ messageBus := bus.NewMessageBus()
+ ch := &TelegramChannel{
+ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
+ chatIDs: make(map[string]int64),
+ ctx: context.Background(),
+ }
+
+ msg := &telego.Message{
+ Text: userText,
+ MessageID: messageID,
+ Chat: telego.Chat{
+ ID: chatID,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: userID,
+ FirstName: userName,
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: replyMessageID,
+ Text: replyText,
+ Caption: replyCaption,
+ From: &telego.User{
+ ID: replyAuthorID,
+ FirstName: replyAuthorName,
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"])
+ assert.Equal(t, expectedContent, inbound.Content)
+}
+
+func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 456,
+ 21,
+ 11,
+ "Alice",
+ "follow up",
+ 99,
+ "old context",
+ "",
+ 12,
+ "Bob",
+ "[quoted user message from Bob]: old context\n\nfollow up",
+ )
+}
+
+func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 789,
+ 22,
+ 13,
+ "Carol",
+ "answer this",
+ 100,
+ "",
+ "caption context",
+ 14,
+ "Dave",
+ "[quoted user message from Dave]: caption context\n\nanswer this",
+ )
+}
+
+func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ if strings.Contains(url, "getMe") {
+ return successUserResponse(t, &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ }), nil
+ }
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
+ ch.ctx = context.Background()
+
+ msg := &telego.Message{
+ Text: "ti ricordi questo file?",
+ MessageID: 23,
+ Chat: telego.Chat{
+ ID: 999,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: 15,
+ FirstName: "Eve",
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: 101,
+ Text: "Fatto! Ho creato il file notizie_2026_03_28.md",
+ From: &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"])
+ assert.Equal(
+ t,
+ "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?",
+ inbound.Content,
+ )
+}
+
+func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) {
+ msg := &telego.Message{
+ Caption: "listen to this",
+ Voice: &telego.Voice{
+ FileID: "voice-file",
+ },
+ }
+
+ assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg))
+}
+
+func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) {
+ msg := &telego.Message{
+ Voice: &telego.Voice{FileID: "voice-file"},
+ Audio: &telego.Audio{FileID: "audio-file"},
+ }
+
+ var calls []string
+ refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string {
+ calls = append(calls, fileID+"|"+ext+"|"+filename)
+ return "ref://" + filename
+ })
+
+ assert.Equal(
+ t,
+ []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"},
+ calls,
+ )
+ assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs)
+}
+
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
diff --git a/pkg/channels/voice_capabilities.go b/pkg/channels/voice_capabilities.go
new file mode 100644
index 000000000..34fd24269
--- /dev/null
+++ b/pkg/channels/voice_capabilities.go
@@ -0,0 +1,58 @@
+package channels
+
+// VoiceCapabilities describes whether ASR (speech-to-text) and TTS (text-to-speech)
+// are available for a channel under the current configuration.
+type VoiceCapabilities struct {
+ ASR bool
+ TTS bool
+}
+
+// VoiceCapabilityProvider is an optional interface for channels that want to
+// explicitly declare their ASR/TTS support.
+type VoiceCapabilityProvider interface {
+ VoiceCapabilities() VoiceCapabilities
+}
+
+// Deprecated: Channels should implement VoiceCapabilityProvider instead.
+// To be removed once all existing capable channels conform to the interface.
+var asrCapableChannels = map[string]bool{
+ "discord": true,
+ "telegram": true,
+ "matrix": true,
+ "qq": true,
+ "weixin": true,
+ "line": true,
+ "feishu": true,
+ "onebot": true,
+}
+
+// DetectVoiceCapabilities returns ASR/TTS availability for a channel, gated by
+// whether providers are configured.
+func DetectVoiceCapabilities(channelName string, ch Channel, asrAvailable bool, ttsAvailable bool) VoiceCapabilities {
+ if ch == nil {
+ return VoiceCapabilities{}
+ }
+
+ if vcp, ok := ch.(VoiceCapabilityProvider); ok {
+ caps := vcp.VoiceCapabilities()
+ if !asrAvailable {
+ caps.ASR = false
+ }
+ if !ttsAvailable {
+ caps.TTS = false
+ }
+ return caps
+ }
+
+ caps := VoiceCapabilities{}
+ if asrAvailable {
+ caps.ASR = asrCapableChannels[channelName]
+ }
+ if ttsAvailable {
+ if _, ok := ch.(MediaSender); ok {
+ caps.TTS = true
+ }
+ }
+
+ return caps
+}
diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go
index 6096b7db3..9689d5171 100644
--- a/pkg/channels/wecom/wecom.go
+++ b/pkg/channels/wecom/wecom.go
@@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S
}, nil
}
-func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
content := strings.TrimSpace(msg.Content)
if content == "" {
- return nil
+ return nil, nil
}
if turn, ok := c.getTurn(msg.ChatID); ok {
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
if err := c.sendStreamReply(turn, content); err == nil {
c.consumeTurn(msg.ChatID, turn)
- return nil
+ return nil, nil
}
}
c.consumeTurn(msg.ChatID, turn)
@@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
if route, ok := c.routes.Get(msg.ChatID); ok {
if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
if err := c.sendActivePush(msg.ChatID, 0, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
-func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID)
@@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
if strings.TrimSpace(part.Ref) == "" {
if caption := strings.TrimSpace(part.Caption); caption != "" {
if err := c.sendActivePush(chatID, chatType, caption); err != nil {
- return err
+ return nil, err
}
}
continue
@@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
if err != nil {
- return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
+ return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
}
func() {
@@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
}
}()
if err != nil {
- return err
+ return nil, err
}
}
- return nil
+ return nil, nil
}
func (c *WeComChannel) connectLoop() {
diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go
index c7a4adfc0..b3a87e246 100644
--- a/pkg/channels/wecom/wecom_test.go
+++ b/pkg/channels/wecom/wecom_test.go
@@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
return wecomTestAck(nil), nil
}
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: "hello",
@@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) {
}
content := strings.Repeat("\u4e2d", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) {
}
content := strings.Repeat("a", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-2",
Parts: []bus.MediaPart{{
diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go
index 4da7f0db9..cf1b45612 100644
--- a/pkg/channels/weixin/media.go
+++ b/pkg/channels/weixin/media.go
@@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(),
}
// SendMedia implements channels.MediaSender.
-func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return basechannels.ErrNotRunning
+ return nil, basechannels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
contextToken := ""
@@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
contextToken, _ = v.(string)
}
if contextToken == "" {
- return fmt.Errorf(
+ return nil, fmt.Errorf(
"weixin send media: missing context token for chat %s: %w",
msg.ChatID,
basechannels.ErrSendFailed,
@@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"ref": part.Ref,
"error": err.Error(),
})
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
func() {
if cleanup != nil {
@@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
- return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
}
}
- return nil
+ return nil, nil
}
diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go
index 65fabe399..a0d0c96b5 100644
--- a/pkg/channels/weixin/weixin.go
+++ b/pkg/channels/weixin/weixin.go
@@ -358,16 +358,16 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
}
// Send implements channels.Channel by sending a text message to the WeChat user.
-func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// We need a context_token to send a reply. It should be stored in the conversation metadata.
@@ -386,7 +386,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
"to_user_id": toUserID,
})
- return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
+ return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
}
if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
@@ -395,10 +395,15 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
}
- return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
+}
+
+// VoiceCapabilities returns the voice capabilities of the channel.
+func (c *WeixinChannel) VoiceCapabilities() channels.VoiceCapabilities {
+ return channels.VoiceCapabilities{ASR: true, TTS: true}
}
diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go
index 70b3e02bf..98622fe37 100644
--- a/pkg/channels/whatsapp/whatsapp.go
+++ b/pkg/channels/whatsapp/whatsapp.go
@@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
// Check ctx before acquiring lock
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
defer c.mu.Unlock()
if c.conn == nil {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
payload := map[string]any{
@@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
data, err := json.Marshal(payload)
if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
+ return nil, fmt.Errorf("failed to marshal message: %w", err)
}
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
_ = c.conn.SetWriteDeadline(time.Time{})
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
_ = c.conn.SetWriteDeadline(time.Time{})
- return nil
+ return nil, nil
}
func (c *WhatsAppChannel) listen() {
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index 188a7c8fa..d0a74a405 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}
-func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
c.mu.Unlock()
if client == nil || !client.IsConnected() {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
// Detect unpaired state: the client is connected (to WhatsApp servers)
// but has not completed QR-login yet, so sending would fail.
if client.Store.ID == nil {
- return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
}
to, err := parseJID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
+ return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
}
waMsg := &waE2E.Message{
@@ -430,9 +430,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
}
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
}
// parseJID converts a chat ID (phone number or JID string) to types.JID.
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 87cb31f9e..7a11d1ab7 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -558,9 +558,9 @@ type DevicesConfig struct {
}
type VoiceConfig struct {
- ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
- EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
- ElevenLabsAPIKey string `json:"elevenlabs_api_key,omitempty" env:"PICOCLAW_VOICE_ELEVENLABS_API_KEY"`
+ ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"`
+ TTSModelName string `json:"tts_model_name,omitempty" env:"PICOCLAW_VOICE_TTS_MODEL_NAME"`
+ EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
// ModelConfig represents a model-centric provider configuration.
@@ -636,13 +636,6 @@ func (c *ModelConfig) SetAPIKey(value string) {
}
}
-type GatewayConfig struct {
- Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
- Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
- HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
- LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
-}
-
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
@@ -836,6 +829,7 @@ type ToolsConfig struct {
Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
+ SendTTS ToolConfig `json:"send_tts" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"`
Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@@ -1288,6 +1282,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.WebFetch.Enabled
case "send_file":
return t.SendFile.Enabled
+ case "send_tts":
+ return t.SendTTS.Enabled
case "write_file":
return t.WriteFile.Enabled
case "mcp":
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 6734257f4..278dfa43a 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1418,6 +1418,38 @@ func TestConfigLogLevelEmpty(t *testing.T) {
}
}
+func TestResolveGatewayLogLevel(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
+ if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ if got := ResolveGatewayLogLevel(cfgPath); got != "debug" {
+ t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug")
+ }
+}
+
+func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) {
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ data := `{"version":1,"gateway":{"log_level":"debug"}}`
+ if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ t.Setenv("PICOCLAW_LOG_LEVEL", "warning")
+ if got := ResolveGatewayLogLevel(cfgPath); got != "warn" {
+ t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn")
+ }
+
+ t.Setenv("PICOCLAW_LOG_LEVEL", "garbage")
+ if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel {
+ t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel)
+ }
+}
+
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index bded97fcd..6eac5d8b9 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -347,7 +347,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
- LogLevel: "warn",
+ LogLevel: DefaultGatewayLogLevel,
},
Tools: ToolsConfig{
FilterSensitiveData: true,
@@ -434,6 +434,9 @@ func DefaultConfig() *Config {
SendFile: ToolConfig{
Enabled: true,
},
+ SendTTS: ToolConfig{
+ Enabled: false,
+ },
MCP: MCPConfig{
ToolConfig: ToolConfig{
Enabled: false,
diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go
new file mode 100644
index 000000000..e9f4085d3
--- /dev/null
+++ b/pkg/config/gateway.go
@@ -0,0 +1,72 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+const DefaultGatewayLogLevel = "warn"
+
+type GatewayConfig struct {
+ Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
+ Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
+ HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
+ LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
+}
+
+func canonicalGatewayLogLevel(level logger.LogLevel) string {
+ switch level {
+ case logger.DEBUG:
+ return "debug"
+ case logger.INFO:
+ return "info"
+ case logger.WARN:
+ return "warn"
+ case logger.ERROR:
+ return "error"
+ case logger.FATAL:
+ return "fatal"
+ default:
+ return DefaultGatewayLogLevel
+ }
+}
+
+func normalizeGatewayLogLevel(logLevel string) string {
+ if level, ok := logger.ParseLevel(logLevel); ok {
+ return canonicalGatewayLogLevel(level)
+ }
+ return DefaultGatewayLogLevel
+}
+
+// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config.
+// Invalid or empty values fall back to the package default.
+func EffectiveGatewayLogLevel(cfg *Config) string {
+ if cfg == nil {
+ return DefaultGatewayLogLevel
+ }
+ return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
+}
+
+// ResolveGatewayLogLevel reads the configured gateway log level without triggering
+// the full config loader, so startup code can apply logging before config load logs run.
+// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
+func ResolveGatewayLogLevel(path string) string {
+ cfg := struct {
+ Gateway GatewayConfig `json:"gateway"`
+ }{
+ Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel},
+ }
+
+ data, err := os.ReadFile(path)
+ if err == nil {
+ _ = json.Unmarshal(data, &cfg)
+ }
+
+ if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
+ cfg.Gateway.LogLevel = envLevel
+ }
+
+ return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
+}
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index a47bf2ac6..8065a0795 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -6,6 +6,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "sort"
"strings"
"sync"
"sync/atomic"
@@ -13,6 +14,8 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/audio/asr"
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
@@ -41,7 +44,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"
)
const (
@@ -61,6 +63,7 @@ type services struct {
ChannelManager *channels.Manager
DeviceService *devices.Service
HealthServer *health.Server
+ VoiceAgentCancel context.CancelFunc
manualReloadChan chan struct{}
reloading atomic.Bool
authToken string
@@ -70,6 +73,27 @@ type startupBlockedProvider struct {
reason string
}
+func logChannelVoiceCapabilities(cm *channels.Manager, asrAvailable bool, ttsAvailable bool) {
+ if cm == nil {
+ return
+ }
+
+ names := cm.GetEnabledChannels()
+ sort.Strings(names)
+ for _, name := range names {
+ ch, ok := cm.GetChannel(name)
+ if !ok {
+ continue
+ }
+ caps := channels.DetectVoiceCapabilities(name, ch, asrAvailable, ttsAvailable)
+ logger.InfoCF("voice", "Channel voice capabilities", map[string]any{
+ "channel": name,
+ "asr": caps.ASR,
+ "tts": caps.TTS,
+ })
+ }
+}
+
func (p *startupBlockedProvider) Chat(
_ context.Context,
_ []providers.Message,
@@ -98,6 +122,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}
defer logger.DisableFileLogging()
+ if debug {
+ logger.SetLevel(logger.DEBUG)
+ } else {
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
+ }
+
cfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Fatalf("error loading config: %v", err)
@@ -109,16 +139,17 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
// Debug mode permanently overrides the config log level to DEBUG.
if debug {
- logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
} else {
- logger.SetLevelFromString(cfg.Gateway.LogLevel)
- logger.Infof("Log level set to %q", cfg.Gateway.LogLevel)
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level set to %q", effectiveLogLevel)
}
// Enforce singleton: write PID file with generated token.
pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port)
if err != nil {
+ logger.Warnf("write pid file failed: %v", err)
return fmt.Errorf("singleton check failed: %w", err)
}
defer pid.RemovePidFile(homePath)
@@ -331,11 +362,14 @@ func setupAndStartServices(
agentLoop.SetChannelManager(runningServices.ChannelManager)
agentLoop.SetMediaStore(runningServices.MediaStore)
- if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
+ transcriber := asr.DetectTranscriber(cfg)
+ if transcriber != nil {
agentLoop.SetTranscriber(transcriber)
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
}
+ ttsAvailable := tts.DetectTTS(cfg) != nil
+
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
@@ -352,6 +386,16 @@ func setupAndStartServices(
return nil, fmt.Errorf("error starting channels: %w", err)
}
+ logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
+
+ if transcriber != nil {
+ // Start Voice Agent Orchestrator after channels are ready.
+ vaCtx, vaCancel := context.WithCancel(context.Background())
+ runningServices.VoiceAgentCancel = vaCancel
+ voiceAgent := asr.NewAgent(msgBus, transcriber)
+ voiceAgent.Start(vaCtx)
+ }
+
fmt.Printf(
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
cfg.Gateway.Host,
@@ -381,6 +425,9 @@ func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Dura
if !isReload && runningServices.ChannelManager != nil {
runningServices.ChannelManager.StopAll(shutdownCtx)
}
+ if runningServices.VoiceAgentCancel != nil {
+ runningServices.VoiceAgentCancel()
+ }
if runningServices.DeviceService != nil {
runningServices.DeviceService.Stop()
}
@@ -476,8 +523,9 @@ func handleConfigReload(
// Debug mode permanently overrides the config log level to DEBUG.
if !debug {
// Update log level last so that reload-related info/warn logs above are not suppressed.
- logger.SetLevelFromString(newCfg.Gateway.LogLevel)
- logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel)
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level changing from current to %q", effectiveLogLevel)
}
return nil
@@ -556,14 +604,22 @@ func restartServices(
fmt.Println(" ✓ Device event service restarted")
}
- transcriber := voice.DetectTranscriber(cfg)
+ transcriber := asr.DetectTranscriber(cfg)
al.SetTranscriber(transcriber)
if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
+
+ // Start Voice Agent Orchestrator on reload
+ vaCtx, vaCancel := context.WithCancel(context.Background())
+ runningServices.VoiceAgentCancel = vaCancel
+ voiceAgent := asr.NewAgent(msgBus, transcriber)
+ voiceAgent.Start(vaCtx)
} else {
logger.InfoCF("voice", "Transcription disabled", nil)
}
+ ttsAvailable := tts.DetectTTS(cfg) != nil
+ logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
// NOTE: PID file is written once at startup and not updated on reload.
// Changing the gateway listen address requires a full restart.
diff --git a/pkg/pid/pidfile.go b/pkg/pid/pidfile.go
index 584b9b2b5..69d02bc65 100644
--- a/pkg/pid/pidfile.go
+++ b/pkg/pid/pidfile.go
@@ -94,6 +94,7 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
os.Remove(tmp)
return nil, fmt.Errorf("failed to rename pid file: %w", err)
}
+ logger.Debugf("wrote pid file: %s success", pidPath)
return data, nil
}
@@ -108,10 +109,12 @@ func ReadPidFileWithCheck(homePath string) *PidFileData {
pidPath := pidFilePath(homePath)
data, err := readPidFileUnlocked(pidPath)
if err != nil {
+ logger.Debugf("failed to read pid file: %s", err)
return nil
}
if !isProcessRunning(data.PID) {
+ logger.Debugf("process not running, remove pid file: %s", pidPath)
os.Remove(pidPath)
return nil
}
diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go
index 9ca29455f..3798c5fd8 100644
--- a/pkg/providers/bedrock/provider_bedrock.go
+++ b/pkg/providers/bedrock/provider_bedrock.go
@@ -208,7 +208,10 @@ func (p *Provider) Chat(
if err != nil {
// Check for SSO token expiration errors and provide actionable guidance
if isSSOTokenError(err) {
- return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err)
+ return nil, fmt.Errorf(
+ "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w",
+ err,
+ )
}
return nil, fmt.Errorf("bedrock converse: %w", err)
}
diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go
index 882c2971c..38a5e26da 100644
--- a/pkg/providers/bedrock/provider_bedrock_test.go
+++ b/pkg/providers/bedrock/provider_bedrock_test.go
@@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) {
expected: true,
},
{
- name: "full SSO error message",
- err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"),
+ name: "full SSO error message",
+ err: fmt.Errorf(
+ "get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token",
+ ),
expected: true,
},
{
- name: "SSO token file missing",
- err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"),
+ name: "SSO token file missing",
+ err: fmt.Errorf(
+ "get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory",
+ ),
expected: true,
},
}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 962e6ae19..16b2ead10 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -17,6 +17,48 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
)
+type protocolMeta struct {
+ defaultAPIBase string
+ emptyAPIKeyAllowed bool
+}
+
+var protocolMetaByName = map[string]protocolMeta{
+ "openai": {defaultAPIBase: "https://api.openai.com/v1"},
+ "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
+ "litellm": {defaultAPIBase: "http://localhost:4000/v1"},
+ "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
+ "novita": {defaultAPIBase: "https://api.novita.ai/openai"},
+ "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"},
+ "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"},
+ "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"},
+ "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"},
+ "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true},
+ "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"},
+ "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"},
+ "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"},
+ "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"},
+ "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
+ "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
+ "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
+ "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
+ "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
+ "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
+ "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
+ "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
+ "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
+ "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
+ "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
+ "avian": {defaultAPIBase: "https://api.avian.io/v1"},
+ "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"},
+ "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
+ "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
+ "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
+}
+
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
func createClaudeAuthProvider() (LLMProvider, error) {
cred, err := getCredential("anthropic")
@@ -56,6 +98,19 @@ func ExtractProtocol(model string) (protocol, modelID string) {
return protocol, modelID
}
+// ResolveAPIBase returns the configured API base, or the protocol default when
+// the model uses an HTTP-based provider family with a known default endpoint.
+func ResolveAPIBase(cfg *config.ModelConfig) string {
+ if cfg == nil {
+ return ""
+ }
+ if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" {
+ return strings.TrimRight(apiBase, "/")
+ }
+ protocol, _ := ExtractProtocol(cfg.Model)
+ return strings.TrimRight(getDefaultAPIBase(protocol), "/")
+}
+
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini),
@@ -154,13 +209,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
- case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
+ case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// All other OpenAI-compatible HTTP providers
- if cfg.APIKey() == "" && cfg.APIBase == "" {
+ if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
}
apiBase := cfg.APIBase
@@ -294,64 +349,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
}
+func isEmptyAPIKeyAllowed(protocol string) bool {
+ meta, ok := protocolMetaByName[protocol]
+ return ok && meta.emptyAPIKeyAllowed
+}
+
+// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests
+// without api_key when using its default local endpoint.
+func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool {
+ protocol = strings.ToLower(strings.TrimSpace(protocol))
+ return isEmptyAPIKeyAllowed(protocol)
+}
+
+// DefaultAPIBaseForProtocol returns the configured default API base for a protocol.
+// It returns empty string if the protocol has no default base.
+func DefaultAPIBaseForProtocol(protocol string) string {
+ protocol = strings.ToLower(strings.TrimSpace(protocol))
+ return getDefaultAPIBase(protocol)
+}
+
// getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string {
- switch protocol {
- case "openai":
- return "https://api.openai.com/v1"
- case "openrouter":
- return "https://openrouter.ai/api/v1"
- case "litellm":
- return "http://localhost:4000/v1"
- case "novita":
- return "https://api.novita.ai/openai"
- case "groq":
- return "https://api.groq.com/openai/v1"
- case "zhipu":
- return "https://open.bigmodel.cn/api/paas/v4"
- case "gemini":
- return "https://generativelanguage.googleapis.com/v1beta"
- case "nvidia":
- return "https://integrate.api.nvidia.com/v1"
- case "ollama":
- return "http://localhost:11434/v1"
- case "moonshot":
- return "https://api.moonshot.cn/v1"
- case "shengsuanyun":
- return "https://router.shengsuanyun.com/api/v1"
- case "deepseek":
- return "https://api.deepseek.com/v1"
- case "cerebras":
- return "https://api.cerebras.ai/v1"
- case "vivgrid":
- return "https://api.vivgrid.com/v1"
- case "volcengine":
- return "https://ark.cn-beijing.volces.com/api/v3"
- case "qwen":
- return "https://dashscope.aliyuncs.com/compatible-mode/v1"
- case "qwen-intl", "qwen-international", "dashscope-intl":
- return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
- case "qwen-us", "dashscope-us":
- return "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
- case "coding-plan", "alibaba-coding", "qwen-coding":
- return "https://coding-intl.dashscope.aliyuncs.com/v1"
- case "coding-plan-anthropic", "alibaba-coding-anthropic":
- return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
- case "vllm":
- return "http://localhost:8000/v1"
- case "mistral":
- return "https://api.mistral.ai/v1"
- case "avian":
- return "https://api.avian.io/v1"
- case "minimax":
- return "https://api.minimaxi.com/v1"
- case "longcat":
- return "https://api.longcat.chat/openai"
- case "modelscope":
- return "https://api-inference.modelscope.cn/v1"
- case "mimo":
- return "https://api.xiaomimimo.com/v1"
- default:
+ meta, ok := protocolMetaByName[protocol]
+ if !ok {
return ""
}
+ return meta.defaultAPIBase
}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index f1fe02cc2..588b81650 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"},
{"deepseek", "deepseek"},
{"ollama", "ollama"},
+ {"lmstudio", "lmstudio"},
{"longcat", "longcat"},
{"modelscope", "modelscope"},
{"mimo", "mimo"},
@@ -153,6 +154,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
}
}
+func TestGetDefaultAPIBase_LMStudio(t *testing.T) {
+ if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" {
+ t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1")
+ }
+}
+
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-litellm",
@@ -173,6 +180,85 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
}
}
+func TestCreateProviderFromConfig_LocalProviders(t *testing.T) {
+ tests := []struct {
+ name string
+ modelName string
+ model string
+ apiKey string
+ wantModelID string
+ }{
+ {
+ name: "LMStudio with API key",
+ modelName: "test-lmstudio",
+ model: "lmstudio/openai/gpt-oss-20b",
+ apiKey: "test-key",
+ wantModelID: "openai/gpt-oss-20b",
+ },
+ {
+ name: "LMStudio without API key",
+ modelName: "test-lmstudio",
+ model: "lmstudio/openai/gpt-oss-20b",
+ apiKey: "",
+ wantModelID: "openai/gpt-oss-20b",
+ },
+ {
+ name: "Ollama with API key",
+ modelName: "test-ollama",
+ model: "ollama/llama3.1:8b",
+ apiKey: "test-key",
+ wantModelID: "llama3.1:8b",
+ },
+ {
+ name: "Ollama without API key",
+ modelName: "test-ollama",
+ model: "ollama/llama3.1:8b",
+ apiKey: "",
+ wantModelID: "llama3.1:8b",
+ },
+ {
+ name: "VLLM with API key",
+ modelName: "test-vllm",
+ model: "vllm/Qwen/Qwen3-8B",
+ apiKey: "test-key",
+ wantModelID: "Qwen/Qwen3-8B",
+ },
+ {
+ name: "VLLM without API key",
+ modelName: "test-vllm",
+ model: "vllm/Qwen/Qwen3-8B",
+ apiKey: "",
+ wantModelID: "Qwen/Qwen3-8B",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: tt.modelName,
+ Model: tt.model,
+ }
+ if tt.apiKey != "" {
+ cfg.SetAPIKey(tt.apiKey)
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != tt.wantModelID {
+ t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID)
+ }
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+ })
+ }
+}
+
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 6d642b2b5..472c14257 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -41,8 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
}
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
- Model: model,
- Hooks: &copilot.SessionHooks{},
+ Model: model,
+ OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
+ Hooks: &copilot.SessionHooks{},
})
if err != nil {
client.Stop()
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 90bc683b8..aa9473731 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -42,6 +42,23 @@ type Option func(*Provider)
const defaultRequestTimeout = common.DefaultRequestTimeout
+var stripModelPrefixProviders = map[string]struct{}{
+ "litellm": {},
+ "moonshot": {},
+ "nvidia": {},
+ "groq": {},
+ "ollama": {},
+ "deepseek": {},
+ "google": {},
+ "openrouter": {},
+ "zhipu": {},
+ "mistral": {},
+ "vivgrid": {},
+ "minimax": {},
+ "novita": {},
+ "lmstudio": {},
+}
+
func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) {
p.maxTokensField = maxTokensField
@@ -397,13 +414,11 @@ func normalizeModel(model, apiBase string) string {
}
prefix := strings.ToLower(before)
- switch prefix {
- case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
- "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
+ if _, ok := stripModelPrefixProviders[prefix]; ok {
return after
- default:
- return model
}
+
+ return model
}
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index ab632ccf3..823b0ff28 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -432,7 +432,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
}
}
-func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {
+func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -474,6 +474,11 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T
input: "ollama/qwen2.5:14b",
wantModel: "qwen2.5:14b",
},
+ {
+ name: "strips lmstudio prefix and keeps nested model",
+ input: "lmstudio/openai/gpt-oss-20b",
+ wantModel: "openai/gpt-oss-20b",
+ },
{
name: "strips deepseek prefix",
input: "deepseek/deepseek-chat",
@@ -579,6 +584,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
}
+ if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
+ t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
+ }
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
diff --git a/pkg/tools/tts_send.go b/pkg/tools/tts_send.go
new file mode 100644
index 000000000..3d569e3f7
--- /dev/null
+++ b/pkg/tools/tts_send.go
@@ -0,0 +1,82 @@
+package tools
+
+import (
+ "context"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/audio/tts"
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+type SendTTSTool struct {
+ provider tts.TTSProvider
+ mediaStore media.MediaStore
+}
+
+func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool {
+ return &SendTTSTool{
+ provider: provider,
+ mediaStore: store,
+ }
+}
+
+func (t *SendTTSTool) Name() string { return "send_tts" }
+
+func (t *SendTTSTool) Description() string {
+ return "Synthesize speech from text and send it as an audio file to the user."
+}
+
+func (t *SendTTSTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{
+ "type": "string",
+ "description": "The text to synthesize into speech. NOTE: Reply in a highly concise, conversational, oral style suitable for text-to-speech. Do not use markdown, emojis, asterisks, or code blocks. Speak naturally.",
+ },
+ "filename": map[string]any{
+ "type": "string",
+ "description": "Optional filename for the audio file (e.g., response.ogg).",
+ },
+ },
+ "required": []string{"text"},
+ }
+}
+
+func (t *SendTTSTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
+func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ text, _ := args["text"].(string)
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ErrorResult("text is required")
+ }
+
+ channel := ToolChannel(ctx)
+ chatID := ToolChatID(ctx)
+ filename, _ := args["filename"].(string)
+
+ ref, err := tts.SynthesizeAndStore(
+ ctx,
+ t.provider,
+ t.mediaStore,
+ text,
+ filename,
+ channel,
+ chatID,
+ )
+ if err != nil {
+ return ErrorResult(err.Error()).WithError(err)
+ }
+
+ // Return with ForUser set to original text, Media containing the audio ref,
+ // and mark as ResponseHandled so the audio is sent immediately without LLM intervention.
+ return &ToolResult{
+ ForLLM: "TTS audio sent",
+ ForUser: text,
+ Media: []string{ref},
+ ResponseHandled: true,
+ }
+}
diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go
index 135ea0ef5..514f9781b 100644
--- a/pkg/utils/http_retry.go
+++ b/pkg/utils/http_retry.go
@@ -4,12 +4,16 @@ import (
"context"
"fmt"
"net/http"
+ "strconv"
"time"
)
const maxRetries = 3
-var retryDelayUnit = time.Second
+var (
+ retryDelayUnit = time.Second
+ maxRetrySleepDuration = 1 * time.Minute
+)
func shouldRetry(statusCode int) bool {
return statusCode == http.StatusTooManyRequests ||
@@ -36,7 +40,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
}
if i < maxRetries-1 {
- if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil {
+ if err = sleepWithCtx(req.Context(), retryDelayForAttempt(resp, i)); err != nil {
if resp != nil {
resp.Body.Close()
}
@@ -47,6 +51,57 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response,
return resp, err
}
+func retryDelayForAttempt(resp *http.Response, attempt int) time.Duration {
+ fallback := retryDelayUnit * time.Duration(attempt+1)
+ if resp == nil || resp.StatusCode != http.StatusTooManyRequests {
+ return clampRetryDelay(fallback)
+ }
+
+ retryAfter := resp.Header.Get("Retry-After")
+ if retryAfter == "" {
+ return clampRetryDelay(fallback)
+ }
+
+ if delay, ok := numericRetryAfterDelay(retryAfter); ok {
+ return delay
+ }
+
+ if when, err := http.ParseTime(retryAfter); err == nil {
+ delay := time.Until(when)
+ if serverDate, err := http.ParseTime(resp.Header.Get("Date")); err == nil {
+ delay = when.Sub(serverDate)
+ }
+ if delay < 0 {
+ return 0
+ }
+ return clampRetryDelay(delay)
+ }
+
+ return clampRetryDelay(fallback)
+}
+
+func numericRetryAfterDelay(retryAfter string) (time.Duration, bool) {
+ seconds, err := strconv.ParseInt(retryAfter, 10, 64)
+ if err != nil || seconds < 0 {
+ return 0, false
+ }
+ maxSeconds := int64(maxRetrySleepDuration / time.Second)
+ if seconds > maxSeconds {
+ return maxRetrySleepDuration, true
+ }
+ return clampRetryDelay(time.Duration(seconds) * time.Second), true
+}
+
+func clampRetryDelay(delay time.Duration) time.Duration {
+ if delay <= 0 {
+ return 0
+ }
+ if delay > maxRetrySleepDuration {
+ return maxRetrySleepDuration
+ }
+ return delay
+}
+
func sleepWithCtx(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go
index d64cd5eda..4d6021ff7 100644
--- a/pkg/utils/http_retry_test.go
+++ b/pkg/utils/http_retry_test.go
@@ -80,6 +80,81 @@ func TestDoRequestWithRetry(t *testing.T) {
}
}
+func TestDoRequestWithRetry_RetryAfter429Honored(t *testing.T) {
+ retryDelayUnit = 10 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "1")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 900*time.Millisecond)
+}
+
+func TestDoRequestWithRetry_RetryAfter429InvalidFallsBack(t *testing.T) {
+ retryDelayUnit = 50 * time.Millisecond
+ t.Cleanup(func() { retryDelayUnit = time.Second })
+
+ attempts := 0
+ var firstAttemptAt time.Time
+ var secondAttemptAt time.Time
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts == 1 {
+ firstAttemptAt = time.Now()
+ w.Header().Set("Retry-After", "invalid")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ if attempts == 2 {
+ secondAttemptAt = time.Now()
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Timeout: 5 * time.Second}
+ req, err := http.NewRequest(http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+
+ resp, err := DoRequestWithRetry(client, req)
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ resp.Body.Close()
+ require.Equal(t, 2, attempts)
+
+ assert.GreaterOrEqual(t, secondAttemptAt.Sub(firstAttemptAt), 45*time.Millisecond)
+ assert.Less(t, secondAttemptAt.Sub(firstAttemptAt), 500*time.Millisecond)
+}
+
func TestDoRequestWithRetry_ContextCancel(t *testing.T) {
// Use a long retry delay so cancellation always hits during sleepWithCtx.
retryDelayUnit = 10 * time.Second
@@ -204,3 +279,87 @@ func TestDoRequestWithRetry_Delay(t *testing.T) {
assert.GreaterOrEqual(t, delays[2], time.Millisecond)
}
+
+func TestRetryDelayForAttempt_DateRetryAfterUsesResponseDateHeader(t *testing.T) {
+ maxRetrySleepDuration = time.Minute
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ serverDate := time.Date(2000, 1, 2, 15, 4, 5, 0, time.UTC)
+ retryAfterAt := serverDate.Add(10 * time.Second)
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{retryAfterAt.Format(http.TimeFormat)},
+ "Date": []string{serverDate.Format(http.TimeFormat)},
+ },
+ }
+
+ assert.Equal(t, 10*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_DateRetryAfterInvalidOrMissingDateFallsBackSafely(t *testing.T) {
+ maxRetrySleepDuration = 30 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ retryAfterAt := time.Now().UTC().Add(3 * time.Second).Format(http.TimeFormat)
+ testcases := []struct {
+ name string
+ header http.Header
+ }{
+ {
+ name: "invalid-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ "Date": []string{"invalid-date"},
+ },
+ },
+ {
+ name: "missing-date-header",
+ header: http.Header{
+ "Retry-After": []string{retryAfterAt},
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: tc.header,
+ }
+
+ delay := retryDelayForAttempt(resp, 0)
+ assert.Greater(t, delay, time.Duration(0))
+ assert.GreaterOrEqual(t, delay, 1500*time.Millisecond)
+ assert.LessOrEqual(t, delay, 5*time.Second)
+ })
+ }
+}
+
+func TestRetryDelayForAttempt_RetryAfterIsCapped(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"999999"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
+
+func TestRetryDelayForAttempt_RetryAfterNumericOverflowStillCaps(t *testing.T) {
+ maxRetrySleepDuration = 2 * time.Second
+ t.Cleanup(func() { maxRetrySleepDuration = time.Minute })
+
+ resp := &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Header: http.Header{
+ "Retry-After": []string{"9223372036854775807"},
+ },
+ }
+
+ assert.Equal(t, 2*time.Second, retryDelayForAttempt(resp, 0))
+}
diff --git a/pkg/voice/groq_transcriber.go b/pkg/voice/groq_transcriber.go
deleted file mode 100644
index b42e598f7..000000000
--- a/pkg/voice/groq_transcriber.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package voice
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "mime/multipart"
- "net/http"
- "os"
- "path/filepath"
- "time"
-
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/utils"
-)
-
-type GroqTranscriber struct {
- apiKey string
- apiBase string
- httpClient *http.Client
-}
-
-func NewGroqTranscriber(apiKey string) *GroqTranscriber {
- logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""})
-
- apiBase := "https://api.groq.com/openai/v1"
- return &GroqTranscriber{
- apiKey: apiKey,
- apiBase: apiBase,
- httpClient: &http.Client{
- Timeout: 60 * time.Second,
- },
- }
-}
-
-func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
- logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath})
-
- audioFile, err := os.Open(audioFilePath)
- if err != nil {
- logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to open audio file: %w", err)
- }
- defer audioFile.Close()
-
- fileInfo, err := audioFile.Stat()
- if err != nil {
- logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err})
- return nil, fmt.Errorf("failed to get file info: %w", err)
- }
-
- logger.DebugCF("voice", "Audio file details", map[string]any{
- "size_bytes": fileInfo.Size(),
- "file_name": filepath.Base(audioFilePath),
- })
-
- var requestBody bytes.Buffer
- writer := multipart.NewWriter(&requestBody)
-
- part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
- if err != nil {
- logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create form file: %w", err)
- }
-
- copied, err := io.Copy(part, audioFile)
- if err != nil {
- logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to copy file content: %w", err)
- }
-
- logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
-
- if err = writer.WriteField("model", "whisper-large-v3"); 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)
- }
-
- if err = writer.WriteField("response_format", "json"); err != nil {
- logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to write response_format field: %w", err)
- }
-
- if err = writer.Close(); err != nil {
- logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to close multipart writer: %w", err)
- }
-
- url := t.apiBase + "/audio/transcriptions"
- req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
- if err != nil {
- logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- 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{
- "url": url,
- "request_size_bytes": requestBody.Len(),
- "file_size_bytes": fileInfo.Size(),
- })
-
- resp, err := t.httpClient.Do(req)
- if err != nil {
- logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- logger.ErrorCF("voice", "API error", map[string]any{
- "status_code": resp.StatusCode,
- "response": string(body),
- })
- return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- logger.DebugCF("voice", "Received response from Groq API", map[string]any{
- "status_code": resp.StatusCode,
- "response_size_bytes": len(body),
- })
-
- var result TranscriptionResponse
- if err := json.Unmarshal(body, &result); err != nil {
- logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err})
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- logger.InfoCF("voice", "Transcription completed successfully", map[string]any{
- "text_length": len(result.Text),
- "language": result.Language,
- "duration_seconds": result.Duration,
- "transcription_preview": utils.Truncate(result.Text, 50),
- })
-
- return &result, nil
-}
-
-func (t *GroqTranscriber) Name() string {
- return "groq"
-}
diff --git a/pkg/voice/groq_transcriber_test.go b/pkg/voice/groq_transcriber_test.go
deleted file mode 100644
index fdcaa7580..000000000
--- a/pkg/voice/groq_transcriber_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package voice
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-)
-
-var _ Transcriber = (*GroqTranscriber)(nil)
-
-func TestGroqTranscriberName(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- if got := tr.Name(); got != "groq" {
- t.Errorf("Name() = %q, want %q", got, "groq")
- }
-}
-
-func TestGroqTranscribe(t *testing.T) {
- // Write a minimal fake audio file so the transcriber can open and send it.
- tmpDir := t.TempDir()
- audioPath := filepath.Join(tmpDir, "clip.ogg")
- if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil {
- t.Fatalf("failed to write fake audio file: %v", err)
- }
-
- t.Run("success", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/audio/transcriptions" {
- t.Errorf("unexpected path: %s", r.URL.Path)
- }
- if r.Header.Get("Authorization") != "Bearer sk-test" {
- t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization"))
- }
- w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(TranscriptionResponse{
- Text: "hello world",
- Language: "en",
- Duration: 1.5,
- })
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-test")
- tr.apiBase = srv.URL
-
- resp, err := tr.Transcribe(context.Background(), audioPath)
- if err != nil {
- t.Fatalf("Transcribe() error: %v", err)
- }
- if resp.Text != "hello world" {
- t.Errorf("Text = %q, want %q", resp.Text, "hello world")
- }
- if resp.Language != "en" {
- t.Errorf("Language = %q, want %q", resp.Language, "en")
- }
- })
-
- t.Run("api error", func(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized)
- }))
- defer srv.Close()
-
- tr := NewGroqTranscriber("sk-bad")
- tr.apiBase = srv.URL
-
- _, err := tr.Transcribe(context.Background(), audioPath)
- if err == nil {
- t.Fatal("expected error for non-200 response, got nil")
- }
- })
-
- t.Run("missing file", func(t *testing.T) {
- tr := NewGroqTranscriber("sk-test")
- _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
- if err == nil {
- t.Fatal("expected error for missing file, got nil")
- }
- })
-}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
deleted file mode 100644
index f56fdeedd..000000000
--- a/pkg/voice/transcriber.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package voice
-
-import (
- "context"
- "strings"
-
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/providers"
-)
-
-type Transcriber interface {
- Name() string
- Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
-}
-
-type TranscriptionResponse struct {
- Text string `json:"text"`
- Language string `json:"language,omitempty"`
- Duration float64 `json:"duration,omitempty"`
-}
-
-func supportsAudioTranscription(model string) bool {
- protocol, _ := providers.ExtractProtocol(model)
-
- switch protocol {
- case "openai", "azure", "azure-openai",
- "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
- "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
- "qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
- "coding-plan", "alibaba-coding", "qwen-coding":
- // These protocols all go through the OpenAI-compatible or Azure provider path in
- // providers.CreateProviderFromConfig, so they are the only ones that can supply
- // the audio media payload shape expected by NewAudioModelTranscriber.
-
- // TODO: Further restrict this by modelID, since not every model under these
- // protocols supports audio transcription.
- return true
- default:
- return false
- }
-}
-
-// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or
-// nil if no supported transcription provider is configured.
-func DetectTranscriber(cfg *config.Config) Transcriber {
- if modelName := strings.TrimSpace(cfg.Voice.ModelName); modelName != "" {
- modelCfg, err := cfg.GetModelConfig(modelName)
- if err != nil {
- return nil
- }
- if supportsAudioTranscription(modelCfg.Model) {
- return NewAudioModelTranscriber(modelCfg)
- }
- }
-
- // ElevenLabs voice config (supports Scribe STT).
- if key := strings.TrimSpace(cfg.Voice.ElevenLabsAPIKey); key != "" {
- return NewElevenLabsTranscriber(key)
- }
- // Fall back to any model-list entry that uses the groq/ protocol.
- for _, mc := range cfg.ModelList {
- if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey() != "" {
- return NewGroqTranscriber(mc.APIKey())
- }
- }
- return nil
-}
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index 7c8e21308..5490b4e18 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -20,6 +20,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
+func (h *Handler) applyRuntimeLogLevel() {
+ if h.debug {
+ logger.SetLevel(logger.DEBUG)
+ return
+ }
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
+}
+
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
@@ -80,8 +88,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
- logger.Infof("configuration updated successfully")
-
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
@@ -89,6 +95,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Refresh cached pico token in case user changed it.
refreshPicoToken(&cfg)
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
@@ -133,7 +141,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
-
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
@@ -187,6 +194,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
// Refresh cached pico token in case user changed it.
refreshPicoToken(&newCfg)
+ h.applyRuntimeLogLevel()
+ logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index d3e25a7f9..a90145f3c 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -9,8 +9,38 @@ import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
+func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
+ t.Helper()
+
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != want {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
+ }
+}
+
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -251,6 +281,68 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
}
}
+func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPut, `{
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "custom-default"
+ }
+ },
+ "gateway": {
+ "log_level": "error"
+ },
+ "model_list": [
+ {
+ "model_name": "custom-default",
+ "model": "openai/gpt-4o",
+ "api_keys": ["sk-default"]
+ }
+ ]
+ }`, logger.ERROR)
+}
+
+func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) {
+ assertGatewayLogLevelApplied(t, http.MethodPatch, `{
+ "gateway": {
+ "log_level": "debug"
+ }
+ }`, logger.DEBUG)
+}
+
+func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ initialLevel := logger.GetLevel()
+ logger.SetLevel(logger.INFO)
+ t.Cleanup(func() {
+ logger.SetLevel(initialLevel)
+ })
+
+ h := NewHandler(configPath)
+ h.SetDebug(true)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
+ "gateway": {
+ "log_level": "error"
+ }
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if got := logger.GetLevel(); got != logger.DEBUG {
+ t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
+ }
+}
+
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go
index ce3a9ca1e..b54e55bac 100644
--- a/web/backend/api/gateway.go
+++ b/web/backend/api/gateway.go
@@ -59,6 +59,24 @@ func refreshPicoTokensLocked(configPath string) {
gateway.picoToken = cfg.Channels.Pico.Token.String()
}
+// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when
+// the launcher has already discovered a running gateway via pidData, but has
+// not yet refreshed the token into memory.
+func ensurePicoTokenCachedLocked(configPath string) {
+ if gateway.picoToken != "" {
+ return
+ }
+ refreshPicoTokensLocked(configPath)
+}
+
+func (h *Handler) gatewayCommandArgs() []string {
+ args := []string{"gateway", "-E"}
+ if h.debug {
+ args = append(args, "-d")
+ }
+ return args
+}
+
const (
protocolKey = "Sec-Websocket-Protocol"
tokenPrefix = "token."
@@ -521,7 +539,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
execPath := utils.FindPicoclawBinary()
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
- cmd = exec.Command(execPath, "gateway", "-E")
+ cmd = exec.Command(execPath, h.gatewayCommandArgs()...)
cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same
@@ -610,6 +628,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
gateway.mu.Lock()
if gateway.cmd == cmd {
gateway.pidData = pd
+ gateway.picoToken = cfg.Channels.Pico.Token.String()
setGatewayRuntimeStatusLocked("running")
}
gateway.mu.Unlock()
@@ -904,34 +923,13 @@ func (h *Handler) gatewayStatusData() map[string]any {
data["pid"] = pidData.PID
gateway.mu.Unlock()
} else {
- // Fallback: probe health endpoint to get pid and status
- _, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
- if err != nil {
- gateway.mu.Lock()
- data["gateway_status"] = gatewayStatusWithoutHealthLocked()
- gateway.pidData = nil
- gateway.mu.Unlock()
- logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err))
- } else {
- logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode))
- if statusCode != http.StatusOK {
- gateway.mu.Lock()
- setGatewayRuntimeStatusLocked("error")
- gateway.pidData = nil
- gateway.mu.Unlock()
- data["gateway_status"] = "error"
- data["status_code"] = statusCode
- } else {
- gateway.mu.Lock()
- setGatewayRuntimeStatusLocked("running")
- bootDefaultModel := gateway.bootDefaultModel
- if bootDefaultModel != "" {
- data["boot_default_model"] = bootDefaultModel
- }
- data["gateway_status"] = "running"
- gateway.mu.Unlock()
- }
- }
+ // Intentionally skip health probe here; the startup goroutine
+ // (startGatewayLocked) already handles liveness detection via
+ // pidFile polling and health fallback.
+ gateway.mu.Lock()
+ data["gateway_status"] = gatewayStatusWithoutHealthLocked()
+ gateway.pidData = nil
+ gateway.mu.Unlock()
}
gatewayStatus, _ := data["gateway_status"].(string)
diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go
index 6190f0c7c..f8e8eadba 100644
--- a/web/backend/api/gateway_host.go
+++ b/web/backend/api/gateway_host.go
@@ -190,12 +190,20 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in
func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort
if wsPort == 0 {
- wsPort = 18800 // default web server port
+ wsPort = 18800
}
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort)
}
host := requestHostName(r)
+ // Use clientVisiblePort only when an explicit port is present in headers
+ // or Host header — do not infer from TLS/scheme, as serverPort takes priority.
+ if p := forwardedPortFirst(r); p != "" {
+ return net.JoinHostPort(host, p)
+ }
+ if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
+ return net.JoinHostPort(host, port)
+ }
return net.JoinHostPort(host, strconv.Itoa(wsPort))
}
diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go
index ca6639f67..2ddb1fd8d 100644
--- a/web/backend/api/gateway_test.go
+++ b/web/backend/api/gateway_test.go
@@ -15,8 +15,11 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/require"
+
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
+ ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/web/backend/utils"
)
@@ -68,6 +71,7 @@ func resetGatewayTestState(t *testing.T) {
originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval
+ t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() {
gatewayHealthGet = originalHealthGet
gatewayRestartGracePeriod = originalRestartGracePeriod
@@ -76,6 +80,8 @@ func resetGatewayTestState(t *testing.T) {
gateway.mu.Lock()
gateway.cmd = nil
+ gateway.pidData = nil
+ gateway.owned = false
gateway.bootDefaultModel = ""
gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped")
@@ -165,6 +171,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
}
}
+func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) {
+ h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
+ h.SetDebug(true)
+
+ args := h.gatewayCommandArgs()
+ want := []string{"gateway", "-E", "-d"}
+ if strings.Join(args, " ") != strings.Join(want, " ") {
+ t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want)
+ }
+}
+
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@@ -430,7 +447,7 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T)
}
}
-func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
+func TestGatewayStatusReportsRunningFromPidProbe(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
@@ -454,6 +471,9 @@ func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
}
+ _, err := ppid.WritePidFile(globalConfigDir(), "localhost", 0)
+ require.NoError(t, err)
+
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
@@ -499,6 +519,8 @@ func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
+ _, err = ppid.WritePidFile(globalConfigDir(), "localhost", 0)
+ require.NoError(t, err)
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go
index aeef85119..98bd501f5 100644
--- a/web/backend/api/model_status.go
+++ b/web/backend/api/model_status.go
@@ -1,25 +1,87 @@
package api
import (
+ "context"
"encoding/json"
"fmt"
+ "hash/fnv"
"net"
"net/http"
"net/url"
+ "strconv"
"strings"
+ "sync"
"time"
+ "golang.org/x/sync/singleflight"
+
"github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
)
-const modelProbeTimeout = 800 * time.Millisecond
+const (
+ modelProbeTimeout = 800 * time.Millisecond
+ modelProbeSuccessBaseInterval = 2 * time.Second
+ modelProbeSuccessMaxInterval = 60 * time.Second
+ modelProbeFailureBaseInterval = 1 * time.Second
+ modelProbeFailureMaxInterval = 30 * time.Second
+ modelProbeBackoffMaxShift = 8
+ modelProbeCacheMaxEntries = 1024
+ modelProbeCacheEntryTTL = 30 * time.Minute
+ modelProbeCacheTrimToEntries = modelProbeCacheMaxEntries * 8 / 10
+ modelProbeTTLGCInterval = 1 * time.Minute
+)
+
+const (
+ modelStatusAvailable = "available"
+ modelStatusUnconfigured = "unconfigured"
+ modelStatusUnreachable = "unreachable"
+)
+
+type modelConfigurationSummary struct {
+ Available bool
+ Status string
+}
var (
probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
+ modelProbeNowFunc = time.Now
+ modelProbeState = newModelProbeCacheState()
)
+type modelProbeCacheState struct {
+ mu sync.RWMutex
+ cache map[string]*modelProbeCacheEntry
+ group singleflight.Group
+ nextTTLGCAt time.Time
+}
+
+type modelProbeCacheEntry struct {
+ lastResult bool
+ hasResult bool
+ successStreak int
+ failureStreak int
+ nextProbeAt time.Time
+ updatedAt time.Time
+}
+
+func newModelProbeCacheState() *modelProbeCacheState {
+ return &modelProbeCacheState{cache: map[string]*modelProbeCacheEntry{}}
+}
+
+func resetModelProbeCache() {
+ modelProbeState.resetForTest()
+}
+
+func (s *modelProbeCacheState) resetForTest() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cache = map[string]*modelProbeCacheEntry{}
+ s.nextTTLGCAt = time.Time{}
+}
+
func hasModelConfiguration(m *config.ModelConfig) bool {
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
apiKey := strings.TrimSpace(m.APIKey())
@@ -42,16 +104,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
return apiKey != ""
}
-// isModelConfigured reports whether a model is currently available to use.
-// Local models must be reachable; remote/API-key models only need saved config.
-func isModelConfigured(m *config.ModelConfig) bool {
+func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
if !hasModelConfiguration(m) {
- return false
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
}
if requiresRuntimeProbe(m) {
- return probeLocalModelAvailability(m)
+ if probeLocalModelAvailability(m) {
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
+ }
+ return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
}
- return true
+ return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
}
func requiresRuntimeProbe(m *config.ModelConfig) bool {
@@ -60,10 +123,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true
}
- switch modelProtocol(m.Model) {
+ protocol := modelProtocol(m.Model)
+
+ switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true
- case "ollama", "vllm":
+ }
+
+ if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase)
}
@@ -76,12 +143,40 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
}
func probeLocalModelAvailability(m *config.ModelConfig) bool {
+ cacheKey := modelProbeCacheKey(m)
+ return modelProbeState.probe(cacheKey, func() bool {
+ return runLocalModelProbe(m)
+ })
+}
+
+func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) bool {
+ now := modelProbeNowFunc()
+ if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
+ return cachedResult
+ }
+
+ v, _, _ := s.group.Do(cacheKey, func() (any, error) {
+ now = modelProbeNowFunc()
+ if cachedResult, ok := s.getCachedResult(cacheKey, now); ok {
+ return cachedResult, nil
+ }
+
+ result := probeFunc()
+ s.setCachedResult(cacheKey, result, now)
+ return result, nil
+ })
+
+ result, _ := v.(bool)
+ return result
+}
+
+func runLocalModelProbe(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m.Model)
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
- case "vllm":
+ case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
@@ -95,16 +190,206 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
}
}
+func modelProbeCacheKey(m *config.ModelConfig) string {
+ protocol, modelID := splitModel(m.Model)
+
+ apiBaseRaw := modelProbeAPIBase(m)
+ apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
+ apiKeyFingerprint := modelProbeAPIKeyFingerprint(m.APIKey())
+
+ var b strings.Builder
+ b.Grow(len(protocol) + len(modelID) + len(apiBase) + len(apiKeyFingerprint) + 8)
+ b.WriteString(protocol)
+ b.WriteByte('|')
+ b.WriteString(modelID)
+ b.WriteByte('|')
+ b.WriteString(apiBase)
+ b.WriteByte('|')
+ b.WriteString(apiKeyFingerprint)
+
+ return b.String()
+}
+
+func modelProbeAPIKeyFingerprint(raw string) string {
+ apiKey := strings.TrimSpace(raw)
+ if apiKey == "" {
+ return "none"
+ }
+
+ h := fnv.New64a()
+ _, _ = h.Write([]byte(apiKey))
+ return strconv.FormatUint(h.Sum64(), 36)
+}
+
+func (s *modelProbeCacheState) getCachedResult(cacheKey string, now time.Time) (bool, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ entry, ok := s.cache[cacheKey]
+ if !ok || !entry.hasResult {
+ return false, false
+ }
+ if now.Before(entry.nextProbeAt) {
+ return entry.lastResult, true
+ }
+ return false, false
+}
+
+func (s *modelProbeCacheState) setCachedResult(cacheKey string, result bool, now time.Time) {
+ s.mu.Lock()
+
+ entry, ok := s.cache[cacheKey]
+ if !ok {
+ entry = &modelProbeCacheEntry{}
+ s.cache[cacheKey] = entry
+ }
+
+ entry.lastResult = result
+ entry.hasResult = true
+ entry.updatedAt = now
+
+ var delay time.Duration
+ if result {
+ entry.successStreak++
+ entry.failureStreak = 0
+ delay = modelProbeBackoffDelay(
+ modelProbeSuccessBaseInterval,
+ modelProbeSuccessMaxInterval,
+ entry.successStreak,
+ )
+ } else {
+ entry.failureStreak++
+ entry.successStreak = 0
+ delay = modelProbeBackoffDelay(
+ modelProbeFailureBaseInterval,
+ modelProbeFailureMaxInterval,
+ entry.failureStreak,
+ )
+ }
+
+ entry.nextProbeAt = now.Add(delay)
+
+ shouldRunTTLGC := modelProbeCacheEntryTTL > 0 && (s.nextTTLGCAt.IsZero() || !now.Before(s.nextTTLGCAt))
+ if shouldRunTTLGC {
+ s.nextTTLGCAt = now.Add(modelProbeTTLGCInterval)
+ }
+ shouldRunSizeGC := len(s.cache) > modelProbeCacheMaxEntries
+ s.mu.Unlock()
+
+ if shouldRunTTLGC || shouldRunSizeGC {
+ s.gc(now, shouldRunTTLGC)
+ }
+}
+
+func (s *modelProbeCacheState) gc(now time.Time, runTTL bool) {
+ type evictionCandidate struct {
+ key string
+ updatedAt time.Time
+ }
+
+ var expireBefore time.Time
+ if runTTL && modelProbeCacheEntryTTL > 0 {
+ expireBefore = now.Add(-modelProbeCacheEntryTTL)
+ }
+
+ s.mu.RLock()
+ cacheLen := len(s.cache)
+ if cacheLen == 0 {
+ s.mu.RUnlock()
+ return
+ }
+
+ expiredKeys := make([]string, 0)
+ if !expireBefore.IsZero() {
+ expiredKeys = make([]string, 0, min(cacheLen/8+1, 64))
+ for key, entry := range s.cache {
+ if entry.updatedAt.Before(expireBefore) {
+ expiredKeys = append(expiredKeys, key)
+ }
+ }
+ }
+
+ effectiveLen := cacheLen - len(expiredKeys)
+ removeCount := max(effectiveLen-modelProbeCacheTrimToEntries, 0)
+
+ candidates := make([]evictionCandidate, 0)
+ if removeCount > 0 {
+ candidates = make([]evictionCandidate, 0, effectiveLen)
+ for key, entry := range s.cache {
+ if !expireBefore.IsZero() && entry.updatedAt.Before(expireBefore) {
+ continue
+ }
+ candidates = append(candidates, evictionCandidate{key: key, updatedAt: entry.updatedAt})
+ }
+ }
+ s.mu.RUnlock()
+
+ if len(expiredKeys) == 0 && len(candidates) == 0 {
+ return
+ }
+
+ toEvict := map[string]time.Time{}
+ for i := 0; i < removeCount && len(candidates) > 0; i++ {
+ oldest := 0
+ for j := 1; j < len(candidates); j++ {
+ if candidates[j].updatedAt.Before(candidates[oldest].updatedAt) {
+ oldest = j
+ }
+ }
+ victim := candidates[oldest]
+ toEvict[victim.key] = victim.updatedAt
+ candidates[oldest] = candidates[len(candidates)-1]
+ candidates = candidates[:len(candidates)-1]
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if !expireBefore.IsZero() {
+ for _, key := range expiredKeys {
+ entry, ok := s.cache[key]
+ if ok && entry.updatedAt.Before(expireBefore) {
+ delete(s.cache, key)
+ }
+ }
+ }
+
+ for key, victimUpdatedAt := range toEvict {
+ entry, ok := s.cache[key]
+ if ok && !entry.updatedAt.After(victimUpdatedAt) {
+ delete(s.cache, key)
+ }
+ }
+}
+
+func modelProbeBackoffDelay(base, maxDelay time.Duration, streak int) time.Duration {
+ if streak <= 0 {
+ streak = 1
+ }
+
+ shift := min(streak-1, modelProbeBackoffMaxShift)
+
+ delay := base * time.Duration(1<