From 6bf87d19f48285c069ba87dbbeb5acd26e224d78 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E9=BE=99=200668001470?=
Date: Mon, 16 Mar 2026 10:04:18 +0800
Subject: [PATCH] fix(irc): normalize channel names from flexible config
---
pkg/channels/irc/irc.go | 432 ++---
pkg/channels/irc/irc_test.go | 326 ++--
pkg/config/config.go | 2088 +++++++++++-----------
pkg/config/flexible_string_slice_test.go | 38 +-
web/backend/api/config_test.go | 214 +--
5 files changed, 1535 insertions(+), 1563 deletions(-)
diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go
index 369968332..2f8458f3a 100644
--- a/pkg/channels/irc/irc.go
+++ b/pkg/channels/irc/irc.go
@@ -1,216 +1,216 @@
-package irc
-
-import (
- "context"
- "crypto/tls"
- "fmt"
- "strings"
-
- "github.com/ergochat/irc-go/ircevent"
- "github.com/ergochat/irc-go/ircmsg"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
- "github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/logger"
-)
-
-// IRCChannel implements the Channel interface for IRC servers.
-type IRCChannel struct {
- *channels.BaseChannel
- config config.IRCConfig
- conn *ircevent.Connection
- ctx context.Context
- cancel context.CancelFunc
-}
-
-// NewIRCChannel creates a new IRC channel.
-func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
- if cfg.Server == "" {
- return nil, fmt.Errorf("irc server is required")
- }
- if cfg.Nick == "" {
- return nil, fmt.Errorf("irc nick is required")
- }
- cfg.Channels = normalizeIRCChannels(cfg.Channels)
-
- base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
- channels.WithMaxMessageLength(400),
- channels.WithGroupTrigger(cfg.GroupTrigger),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
- )
-
- return &IRCChannel{
- BaseChannel: base,
- config: cfg,
- }, nil
-}
-
-// Start connects to the IRC server and begins listening.
-func (c *IRCChannel) Start(ctx context.Context) error {
- logger.InfoC("irc", "Starting IRC channel")
- c.ctx, c.cancel = context.WithCancel(ctx)
-
- user := c.config.User
- if user == "" {
- user = c.config.Nick
- }
- realName := c.config.RealName
- if realName == "" {
- realName = c.config.Nick
- }
- caps := []string(c.config.RequestCaps)
- if len(caps) == 0 {
- caps = []string{"server-time", "message-tags"}
- }
-
- conn := &ircevent.Connection{
- Server: c.config.Server,
- Nick: c.config.Nick,
- User: user,
- RealName: realName,
- Password: c.config.Password,
- UseTLS: c.config.TLS,
- RequestCaps: caps,
- QuitMessage: "Goodbye",
- Debug: false,
- Log: nil,
- }
-
- if c.config.TLS {
- conn.TLSConfig = &tls.Config{
- ServerName: extractHost(c.config.Server),
- }
- }
-
- // SASL auth (takes priority over NickServ)
- if c.config.SASLUser != "" && c.config.SASLPassword != "" {
- conn.SASLLogin = c.config.SASLUser
- conn.SASLPassword = c.config.SASLPassword
- }
-
- // Register event handlers
- conn.AddConnectCallback(func(e ircmsg.Message) {
- c.onConnect(conn)
- })
- conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
- c.onPrivmsg(conn, e)
- })
-
- if err := conn.Connect(); err != nil {
- return fmt.Errorf("irc connect failed: %w", err)
- }
-
- c.conn = conn
-
- // ircevent.Connection.Loop() handles reconnection internally.
- go conn.Loop()
-
- c.SetRunning(true)
- logger.InfoCF("irc", "IRC channel started", map[string]any{
- "server": c.config.Server,
- "nick": c.config.Nick,
- })
- return nil
-}
-
-// Stop disconnects from the IRC server.
-func (c *IRCChannel) Stop(ctx context.Context) error {
- logger.InfoC("irc", "Stopping IRC channel")
- c.SetRunning(false)
-
- if c.conn != nil {
- c.conn.Quit()
- }
- if c.cancel != nil {
- c.cancel()
- }
-
- logger.InfoC("irc", "IRC channel stopped")
- return nil
-}
-
-// Send sends a message to an IRC channel or user.
-func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
- return channels.ErrNotRunning
- }
-
- target := msg.ChatID
- if target == "" {
- return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
- }
-
- if strings.TrimSpace(msg.Content) == "" {
- return nil
- }
-
- // Send each line separately (IRC is line-oriented)
- lines := strings.Split(msg.Content, "\n")
- for _, line := range lines {
- line = strings.TrimRight(line, "\r")
- if line == "" {
- continue
- }
- c.conn.Privmsg(target, line)
- }
-
- logger.DebugCF("irc", "Message sent", map[string]any{
- "target": target,
- "lines": len(lines),
- })
- return nil
-}
-
-// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
-// Requires typing.enabled in config and server support for message-tags capability.
-func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
- noop := func() {}
-
- if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
- return noop, nil
- }
-
- // Check if server supports message-tags (required for TAGMSG)
- if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
- return noop, nil
- }
-
- c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
-
- return func() {
- if c.IsRunning() && c.conn != nil {
- c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
- }
- }, nil
-}
-
-// extractHost returns the hostname portion of a host:port string.
-func extractHost(server string) string {
- host, _, found := strings.Cut(server, ":")
- if found {
- return host
- }
- return server
-}
-
-func normalizeIRCChannels(channels config.FlexibleStringSlice) config.FlexibleStringSlice {
- if len(channels) == 0 {
- return channels
- }
-
- normalized := make(config.FlexibleStringSlice, 0, len(channels))
- for _, channel := range channels {
- channel = strings.TrimSpace(channel)
- if channel == "" {
- continue
- }
- switch channel[0] {
- case '#', '&', '+', '!':
- normalized = append(normalized, channel)
- default:
- normalized = append(normalized, "#"+channel)
- }
- }
- return normalized
-}
+package irc
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "strings"
+
+ "github.com/ergochat/irc-go/ircevent"
+ "github.com/ergochat/irc-go/ircmsg"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// IRCChannel implements the Channel interface for IRC servers.
+type IRCChannel struct {
+ *channels.BaseChannel
+ config config.IRCConfig
+ conn *ircevent.Connection
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewIRCChannel creates a new IRC channel.
+func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
+ if cfg.Server == "" {
+ return nil, fmt.Errorf("irc server is required")
+ }
+ if cfg.Nick == "" {
+ return nil, fmt.Errorf("irc nick is required")
+ }
+ cfg.Channels = normalizeIRCChannels(cfg.Channels)
+
+ base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(400),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
+
+ return &IRCChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// Start connects to the IRC server and begins listening.
+func (c *IRCChannel) Start(ctx context.Context) error {
+ logger.InfoC("irc", "Starting IRC channel")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ user := c.config.User
+ if user == "" {
+ user = c.config.Nick
+ }
+ realName := c.config.RealName
+ if realName == "" {
+ realName = c.config.Nick
+ }
+ caps := []string(c.config.RequestCaps)
+ if len(caps) == 0 {
+ caps = []string{"server-time", "message-tags"}
+ }
+
+ conn := &ircevent.Connection{
+ Server: c.config.Server,
+ Nick: c.config.Nick,
+ User: user,
+ RealName: realName,
+ Password: c.config.Password,
+ UseTLS: c.config.TLS,
+ RequestCaps: caps,
+ QuitMessage: "Goodbye",
+ Debug: false,
+ Log: nil,
+ }
+
+ if c.config.TLS {
+ conn.TLSConfig = &tls.Config{
+ ServerName: extractHost(c.config.Server),
+ }
+ }
+
+ // SASL auth (takes priority over NickServ)
+ if c.config.SASLUser != "" && c.config.SASLPassword != "" {
+ conn.SASLLogin = c.config.SASLUser
+ conn.SASLPassword = c.config.SASLPassword
+ }
+
+ // Register event handlers
+ conn.AddConnectCallback(func(e ircmsg.Message) {
+ c.onConnect(conn)
+ })
+ conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
+ c.onPrivmsg(conn, e)
+ })
+
+ if err := conn.Connect(); err != nil {
+ return fmt.Errorf("irc connect failed: %w", err)
+ }
+
+ c.conn = conn
+
+ // ircevent.Connection.Loop() handles reconnection internally.
+ go conn.Loop()
+
+ c.SetRunning(true)
+ logger.InfoCF("irc", "IRC channel started", map[string]any{
+ "server": c.config.Server,
+ "nick": c.config.Nick,
+ })
+ return nil
+}
+
+// Stop disconnects from the IRC server.
+func (c *IRCChannel) Stop(ctx context.Context) error {
+ logger.InfoC("irc", "Stopping IRC channel")
+ c.SetRunning(false)
+
+ if c.conn != nil {
+ c.conn.Quit()
+ }
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ logger.InfoC("irc", "IRC channel stopped")
+ return nil
+}
+
+// Send sends a message to an IRC channel or user.
+func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ target := msg.ChatID
+ if target == "" {
+ return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
+ }
+
+ if strings.TrimSpace(msg.Content) == "" {
+ return nil
+ }
+
+ // Send each line separately (IRC is line-oriented)
+ lines := strings.Split(msg.Content, "\n")
+ for _, line := range lines {
+ line = strings.TrimRight(line, "\r")
+ if line == "" {
+ continue
+ }
+ c.conn.Privmsg(target, line)
+ }
+
+ logger.DebugCF("irc", "Message sent", map[string]any{
+ "target": target,
+ "lines": len(lines),
+ })
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
+// Requires typing.enabled in config and server support for message-tags capability.
+func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ noop := func() {}
+
+ if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
+ return noop, nil
+ }
+
+ // Check if server supports message-tags (required for TAGMSG)
+ if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
+ return noop, nil
+ }
+
+ c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
+
+ return func() {
+ if c.IsRunning() && c.conn != nil {
+ c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
+ }
+ }, nil
+}
+
+// extractHost returns the hostname portion of a host:port string.
+func extractHost(server string) string {
+ host, _, found := strings.Cut(server, ":")
+ if found {
+ return host
+ }
+ return server
+}
+
+func normalizeIRCChannels(channels config.FlexibleStringSlice) config.FlexibleStringSlice {
+ if len(channels) == 0 {
+ return channels
+ }
+
+ normalized := make(config.FlexibleStringSlice, 0, len(channels))
+ for _, channel := range channels {
+ channel = strings.TrimSpace(channel)
+ if channel == "" {
+ continue
+ }
+ switch channel[0] {
+ case '#', '&', '+', '!':
+ normalized = append(normalized, channel)
+ default:
+ normalized = append(normalized, "#"+channel)
+ }
+ }
+ return normalized
+}
diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go
index 1eb23176b..9ac3042c3 100644
--- a/pkg/channels/irc/irc_test.go
+++ b/pkg/channels/irc/irc_test.go
@@ -1,163 +1,163 @@
-package irc
-
-import (
- "reflect"
- "testing"
-
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func TestNewIRCChannel(t *testing.T) {
- msgBus := bus.NewMessageBus()
-
- t.Run("missing server", func(t *testing.T) {
- cfg := config.IRCConfig{Nick: "bot"}
- _, err := NewIRCChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing server, got nil")
- }
- })
-
- t.Run("missing nick", func(t *testing.T) {
- cfg := config.IRCConfig{Server: "irc.example.com:6667"}
- _, err := NewIRCChannel(cfg, msgBus)
- if err == nil {
- t.Error("expected error for missing nick, got nil")
- }
- })
-
- t.Run("valid config", func(t *testing.T) {
- cfg := config.IRCConfig{
- Server: "irc.example.com:6667",
- Nick: "testbot",
- Channels: []string{"#test"},
- }
- ch, err := NewIRCChannel(cfg, msgBus)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if ch.Name() != "irc" {
- t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
- }
- if ch.IsRunning() {
- t.Error("new channel should not be running")
- }
- })
-
- t.Run("normalizes auto join channels", func(t *testing.T) {
- cfg := config.IRCConfig{
- Server: "irc.example.com:6667",
- Nick: "testbot",
- Channels: []string{" general ", "#already", "&ops", "+local", "!safe", "", "news"},
- }
- ch, err := NewIRCChannel(cfg, msgBus)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-
- want := config.FlexibleStringSlice{"#general", "#already", "&ops", "+local", "!safe", "#news"}
- if !reflect.DeepEqual(ch.config.Channels, want) {
- t.Fatalf("Channels = %#v, want %#v", ch.config.Channels, want)
- }
- })
-}
-
-func TestExtractHost(t *testing.T) {
- tests := []struct {
- server string
- want string
- }{
- {"irc.libera.chat:6697", "irc.libera.chat"},
- {"localhost:6667", "localhost"},
- {"irc.example.com", "irc.example.com"},
- {"", ""},
- }
-
- for _, tt := range tests {
- t.Run(tt.server, func(t *testing.T) {
- got := extractHost(tt.server)
- if got != tt.want {
- t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
- }
- })
- }
-}
-
-func TestNickMentionedAt(t *testing.T) {
- tests := []struct {
- name string
- content string
- nick string
- want int
- }{
- {"colon prefix", "bot: hello", "bot", 0},
- {"comma prefix", "bot, hello", "bot", 0},
- {"case insensitive", "BOT: hello", "bot", 0},
- {"word boundary mid", "hey bot what's up", "bot", 4},
- {"no mention", "hello world", "bot", -1},
- {"substring mismatch", "robotics are cool", "bot", -1},
- {"nick at end", "hello bot", "bot", 6},
- {"empty content", "", "bot", -1},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := nickMentionedAt(tt.content, tt.nick)
- if got != tt.want {
- t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
- }
- })
- }
-}
-
-func TestIsBotMentioned(t *testing.T) {
- tests := []struct {
- name string
- content string
- nick string
- want bool
- }{
- {"colon prefix", "bot: hello", "bot", true},
- {"comma prefix", "bot, hello", "bot", true},
- {"case insensitive", "BOT: hello", "bot", true},
- {"word boundary mid", "hey bot what's up", "bot", true},
- {"no mention", "hello world", "bot", false},
- {"substring mismatch", "robotics are cool", "bot", false},
- {"nick at end", "hello bot", "bot", true},
- {"empty content", "", "bot", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := isBotMentioned(tt.content, tt.nick)
- if got != tt.want {
- t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
- }
- })
- }
-}
-
-func TestStripBotMention(t *testing.T) {
- tests := []struct {
- name string
- content string
- nick string
- want string
- }{
- {"colon prefix", "bot: hello there", "bot", "hello there"},
- {"comma prefix", "bot, help me", "bot", "help me"},
- {"case insensitive", "BOT: hello", "bot", "hello"},
- {"no prefix match", "hello bot", "bot", "hello bot"},
- {"only prefix", "bot:", "bot", ""},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := stripBotMention(tt.content, tt.nick)
- if got != tt.want {
- t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
- }
- })
- }
-}
+package irc
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestNewIRCChannel(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("missing server", func(t *testing.T) {
+ cfg := config.IRCConfig{Nick: "bot"}
+ _, err := NewIRCChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing server, got nil")
+ }
+ })
+
+ t.Run("missing nick", func(t *testing.T) {
+ cfg := config.IRCConfig{Server: "irc.example.com:6667"}
+ _, err := NewIRCChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing nick, got nil")
+ }
+ })
+
+ t.Run("valid config", func(t *testing.T) {
+ cfg := config.IRCConfig{
+ Server: "irc.example.com:6667",
+ Nick: "testbot",
+ Channels: []string{"#test"},
+ }
+ ch, err := NewIRCChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch.Name() != "irc" {
+ t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
+ }
+ if ch.IsRunning() {
+ t.Error("new channel should not be running")
+ }
+ })
+
+ t.Run("normalizes auto join channels", func(t *testing.T) {
+ cfg := config.IRCConfig{
+ Server: "irc.example.com:6667",
+ Nick: "testbot",
+ Channels: []string{" general ", "#already", "&ops", "+local", "!safe", "", "news"},
+ }
+ ch, err := NewIRCChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ want := config.FlexibleStringSlice{"#general", "#already", "&ops", "+local", "!safe", "#news"}
+ if !reflect.DeepEqual(ch.config.Channels, want) {
+ t.Fatalf("Channels = %#v, want %#v", ch.config.Channels, want)
+ }
+ })
+}
+
+func TestExtractHost(t *testing.T) {
+ tests := []struct {
+ server string
+ want string
+ }{
+ {"irc.libera.chat:6697", "irc.libera.chat"},
+ {"localhost:6667", "localhost"},
+ {"irc.example.com", "irc.example.com"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.server, func(t *testing.T) {
+ got := extractHost(tt.server)
+ if got != tt.want {
+ t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNickMentionedAt(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want int
+ }{
+ {"colon prefix", "bot: hello", "bot", 0},
+ {"comma prefix", "bot, hello", "bot", 0},
+ {"case insensitive", "BOT: hello", "bot", 0},
+ {"word boundary mid", "hey bot what's up", "bot", 4},
+ {"no mention", "hello world", "bot", -1},
+ {"substring mismatch", "robotics are cool", "bot", -1},
+ {"nick at end", "hello bot", "bot", 6},
+ {"empty content", "", "bot", -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := nickMentionedAt(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsBotMentioned(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want bool
+ }{
+ {"colon prefix", "bot: hello", "bot", true},
+ {"comma prefix", "bot, hello", "bot", true},
+ {"case insensitive", "BOT: hello", "bot", true},
+ {"word boundary mid", "hey bot what's up", "bot", true},
+ {"no mention", "hello world", "bot", false},
+ {"substring mismatch", "robotics are cool", "bot", false},
+ {"nick at end", "hello bot", "bot", true},
+ {"empty content", "", "bot", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isBotMentioned(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStripBotMention(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want string
+ }{
+ {"colon prefix", "bot: hello there", "bot", "hello there"},
+ {"comma prefix", "bot, help me", "bot", "help me"},
+ {"case insensitive", "BOT: hello", "bot", "hello"},
+ {"no prefix match", "hello bot", "bot", "hello bot"},
+ {"only prefix", "bot:", "bot", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stripBotMention(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 33201129b..53cfc27b8 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -1,74 +1,76 @@
-package config
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "sync/atomic"
-
- "github.com/caarlos0/env/v11"
-
- "github.com/sipeed/picoclaw/pkg/fileutil"
-)
-
-// rrCounter is a global counter for round-robin load balancing across models.
-var rrCounter atomic.Uint64
-
-// FlexibleStringSlice is a []string that also accepts JSON numbers,
-// so allow_from can contain both "123" and 123.
-// It also supports parsing comma-separated strings from environment variables,
-// including both English (,) and Chinese (,) commas.
-type FlexibleStringSlice []string
-
-func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
- // Try []string first
- var ss []string
- if err := json.Unmarshal(data, &ss); err == nil {
- *f = ss
- return nil
- }
-
- // Accept a single string so config patch APIs can submit comma-separated values.
- var s string
- if err := json.Unmarshal(data, &s); err == nil {
- *f = parseFlexibleStringSlice(s)
- return nil
- }
-
- // Try []interface{} to handle mixed types
- var raw []any
- if err := json.Unmarshal(data, &raw); err != nil {
- return err
- }
-
- result := make([]string, 0, len(raw))
- for _, v := range raw {
- switch val := v.(type) {
- case string:
- result = append(result, val)
- case float64:
- result = append(result, fmt.Sprintf("%.0f", val))
- default:
- result = append(result, fmt.Sprintf("%v", val))
- }
- }
- *f = result
- return nil
-}
-
-// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
-// It handles comma-separated values with both English (,) and Chinese (,) commas.
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "sync/atomic"
+
+ "github.com/caarlos0/env/v11"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+)
+
+// rrCounter is a global counter for round-robin load balancing across models.
+var rrCounter atomic.Uint64
+
+// FlexibleStringSlice is a []string that also accepts JSON numbers,
+// so allow_from can contain both "123" and 123.
+// It also supports parsing comma-separated strings from environment variables,
+// including both English (,) and Chinese (,) commas.
+type FlexibleStringSlice []string
+
+func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
+ // Try []string first
+ var ss []string
+ if err := json.Unmarshal(data, &ss); err == nil {
+ *f = ss
+ return nil
+ }
+
+ // Accept a comma-separated string for channels and allow_from style fields.
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ *f = parseFlexibleStringSlice(s)
+ return nil
+ }
+
+ // Try []interface{} to handle mixed types
+ var raw []any
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+
+ result := make([]string, 0, len(raw))
+ for _, v := range raw {
+ switch val := v.(type) {
+ case string:
+ result = append(result, val)
+ case float64:
+ result = append(result, fmt.Sprintf("%.0f", val))
+ default:
+ result = append(result, fmt.Sprintf("%v", val))
+ }
+ }
+ *f = result
+ return nil
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
+// It handles comma-separated values with both English (,) and Chinese (,) commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
+
*f = parseFlexibleStringSlice(string(text))
return nil
}
func parseFlexibleStringSlice(s string) FlexibleStringSlice {
+ // Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, ",", ",")
parts := strings.Split(s, ",")
@@ -81,981 +83,989 @@ func parseFlexibleStringSlice(s string) FlexibleStringSlice {
}
return result
}
-
-type Config struct {
- Agents AgentsConfig `json:"agents"`
- Bindings []AgentBinding `json:"bindings,omitempty"`
- Session SessionConfig `json:"session,omitempty"`
- Channels ChannelsConfig `json:"channels"`
- Providers ProvidersConfig `json:"providers,omitempty"`
- ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
- Gateway GatewayConfig `json:"gateway"`
- Tools ToolsConfig `json:"tools"`
- Heartbeat HeartbeatConfig `json:"heartbeat"`
- Devices DevicesConfig `json:"devices"`
- Voice VoiceConfig `json:"voice"`
- // BuildInfo contains build-time version information
- BuildInfo BuildInfo `json:"build_info,omitempty"`
-}
-
-// BuildInfo contains build-time version information
-type BuildInfo struct {
- Version string `json:"version"`
- GitCommit string `json:"git_commit"`
- BuildTime string `json:"build_time"`
- GoVersion string `json:"go_version"`
-}
-
-// MarshalJSON implements custom JSON marshaling for Config
-// to omit providers section when empty and session when empty
-func (c Config) MarshalJSON() ([]byte, error) {
- type Alias Config
- aux := &struct {
- Providers *ProvidersConfig `json:"providers,omitempty"`
- Session *SessionConfig `json:"session,omitempty"`
- *Alias
- }{
- Alias: (*Alias)(&c),
- }
-
- // Only include providers if not empty
- if !c.Providers.IsEmpty() {
- aux.Providers = &c.Providers
- }
-
- // Only include session if not empty
- if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
- aux.Session = &c.Session
- }
-
- return json.Marshal(aux)
-}
-
-type AgentsConfig struct {
- Defaults AgentDefaults `json:"defaults"`
- List []AgentConfig `json:"list,omitempty"`
-}
-
-// AgentModelConfig supports both string and structured model config.
-// String format: "gpt-4" (just primary, no fallbacks)
-// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
-type AgentModelConfig struct {
- Primary string `json:"primary,omitempty"`
- Fallbacks []string `json:"fallbacks,omitempty"`
-}
-
-func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
- var s string
- if err := json.Unmarshal(data, &s); err == nil {
- m.Primary = s
- m.Fallbacks = nil
- return nil
- }
- type raw struct {
- Primary string `json:"primary"`
- Fallbacks []string `json:"fallbacks"`
- }
- var r raw
- if err := json.Unmarshal(data, &r); err != nil {
- return err
- }
- m.Primary = r.Primary
- m.Fallbacks = r.Fallbacks
- return nil
-}
-
-func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
- if len(m.Fallbacks) == 0 && m.Primary != "" {
- return json.Marshal(m.Primary)
- }
- type raw struct {
- Primary string `json:"primary,omitempty"`
- Fallbacks []string `json:"fallbacks,omitempty"`
- }
- return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
-}
-
-type AgentConfig struct {
- ID string `json:"id"`
- Default bool `json:"default,omitempty"`
- Name string `json:"name,omitempty"`
- Workspace string `json:"workspace,omitempty"`
- Model *AgentModelConfig `json:"model,omitempty"`
- Skills []string `json:"skills,omitempty"`
- Subagents *SubagentsConfig `json:"subagents,omitempty"`
-}
-
-type SubagentsConfig struct {
- AllowAgents []string `json:"allow_agents,omitempty"`
- Model *AgentModelConfig `json:"model,omitempty"`
-}
-
-type PeerMatch struct {
- Kind string `json:"kind"`
- ID string `json:"id"`
-}
-
-type BindingMatch struct {
- Channel string `json:"channel"`
- AccountID string `json:"account_id,omitempty"`
- Peer *PeerMatch `json:"peer,omitempty"`
- GuildID string `json:"guild_id,omitempty"`
- TeamID string `json:"team_id,omitempty"`
-}
-
-type AgentBinding struct {
- AgentID string `json:"agent_id"`
- Match BindingMatch `json:"match"`
-}
-
-type SessionConfig struct {
- DMScope string `json:"dm_scope,omitempty"`
- IdentityLinks map[string][]string `json:"identity_links,omitempty"`
-}
-
-// RoutingConfig controls the intelligent model routing feature.
-// When enabled, each incoming message is scored against structural features
-// (message length, code blocks, tool call history, conversation depth, attachments).
-// Messages scoring below Threshold are sent to LightModel; all others use the
-// agent's primary model. This reduces cost and latency for simple tasks without
-// requiring any keyword matching — all scoring is language-agnostic.
-type RoutingConfig struct {
- Enabled bool `json:"enabled"`
- LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
- Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
-}
-
-type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
- ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
- ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
- SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
- SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
- MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
- Routing *RoutingConfig `json:"routing,omitempty"`
-}
-
-const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
-
-func (d *AgentDefaults) GetMaxMediaSize() int {
- if d.MaxMediaSize > 0 {
- return d.MaxMediaSize
- }
- return DefaultMaxMediaSize
-}
-
-// GetModelName returns the effective model name for the agent defaults.
-// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
-func (d *AgentDefaults) GetModelName() string {
- if d.ModelName != "" {
- return d.ModelName
- }
- return d.Model
-}
-
-type ChannelsConfig struct {
- WhatsApp WhatsAppConfig `json:"whatsapp"`
- Telegram TelegramConfig `json:"telegram"`
- Feishu FeishuConfig `json:"feishu"`
- Discord DiscordConfig `json:"discord"`
- MaixCam MaixCamConfig `json:"maixcam"`
- QQ QQConfig `json:"qq"`
- DingTalk DingTalkConfig `json:"dingtalk"`
- Slack SlackConfig `json:"slack"`
- Matrix MatrixConfig `json:"matrix"`
- LINE LINEConfig `json:"line"`
- OneBot OneBotConfig `json:"onebot"`
- WeCom WeComConfig `json:"wecom"`
- WeComApp WeComAppConfig `json:"wecom_app"`
- WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
- Pico PicoConfig `json:"pico"`
- IRC IRCConfig `json:"irc"`
-}
-
-// GroupTriggerConfig controls when the bot responds in group chats.
-type GroupTriggerConfig struct {
- MentionOnly bool `json:"mention_only,omitempty"`
- Prefixes []string `json:"prefixes,omitempty"`
-}
-
-// TypingConfig controls typing indicator behavior (Phase 10).
-type TypingConfig struct {
- Enabled bool `json:"enabled,omitempty"`
-}
-
-// PlaceholderConfig controls placeholder message behavior (Phase 10).
-type PlaceholderConfig struct {
- Enabled bool `json:"enabled,omitempty"`
- Text string `json:"text,omitempty"`
-}
-
-type WhatsAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
- BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
- UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
- SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
-}
-
-type TelegramConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
- BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
-}
-
-type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
- VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
- RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
-}
-
-type DiscordConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
- Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
- MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
-}
-
-type MaixCamConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
- Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
- Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
-}
-
-type QQConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
- SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
-}
-
-type DingTalkConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
- ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
- ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
-}
-
-type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
-}
-
-type MatrixConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
- Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
- UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
- DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
- JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
- MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
-}
-
-type LINEConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
- ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
- ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
-}
-
-type OneBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
- WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
- AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
- ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
- GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
-}
-
-type WeComConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
- WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
-}
-
-type WeComAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
-}
-
-type WeComAIBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
- MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
- WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
-}
-
-type PicoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
- AllowTokenQuery bool `json:"allow_token_query,omitempty"`
- AllowOrigins []string `json:"allow_origins,omitempty"`
- PingInterval int `json:"ping_interval,omitempty"`
- ReadTimeout int `json:"read_timeout,omitempty"`
- WriteTimeout int `json:"write_timeout,omitempty"`
- MaxConnections int `json:"max_connections,omitempty"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
-}
-
-type IRCConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
- Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
- TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
- Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
- User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
- RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
- Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
- NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
- SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
- SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
- Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
- RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Typing TypingConfig `json:"typing,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
-}
-
-type HeartbeatConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
- Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
-}
-
-type DevicesConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
- MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
-}
-
-type VoiceConfig struct {
- EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
-}
-
-type ProvidersConfig struct {
- Anthropic ProviderConfig `json:"anthropic"`
- OpenAI OpenAIProviderConfig `json:"openai"`
- LiteLLM ProviderConfig `json:"litellm"`
- OpenRouter ProviderConfig `json:"openrouter"`
- Groq ProviderConfig `json:"groq"`
- Zhipu ProviderConfig `json:"zhipu"`
- VLLM ProviderConfig `json:"vllm"`
- Gemini ProviderConfig `json:"gemini"`
- Nvidia ProviderConfig `json:"nvidia"`
- Ollama ProviderConfig `json:"ollama"`
- Moonshot ProviderConfig `json:"moonshot"`
- ShengSuanYun ProviderConfig `json:"shengsuanyun"`
- DeepSeek ProviderConfig `json:"deepseek"`
- Cerebras ProviderConfig `json:"cerebras"`
- Vivgrid ProviderConfig `json:"vivgrid"`
- VolcEngine ProviderConfig `json:"volcengine"`
- GitHubCopilot ProviderConfig `json:"github_copilot"`
- Antigravity ProviderConfig `json:"antigravity"`
- Qwen ProviderConfig `json:"qwen"`
- Mistral ProviderConfig `json:"mistral"`
- Avian ProviderConfig `json:"avian"`
- Minimax ProviderConfig `json:"minimax"`
- LongCat ProviderConfig `json:"longcat"`
-}
-
-// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
-// Note: WebSearch is an optimization option and doesn't count as "non-empty"
-func (p ProvidersConfig) IsEmpty() bool {
- return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
- p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
- p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
- p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
- p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
- p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
- p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
- p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
- p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
- p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
- p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
- p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
- p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
- p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
- p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
- p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
- p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
- p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
- p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
- p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
- p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
- p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
- p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
-}
-
-// MarshalJSON implements custom JSON marshaling for ProvidersConfig
-// to omit the entire section when empty
-func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
- if p.IsEmpty() {
- return []byte("null"), nil
- }
- type Alias ProvidersConfig
- return json.Marshal((*Alias)(&p))
-}
-
-type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
-}
-
-type OpenAIProviderConfig struct {
- ProviderConfig
- WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
-}
-
-// ModelConfig represents a model-centric provider configuration.
-// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
-// The model field uses protocol prefix format: [protocol/]model-identifier
-// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
-// Default protocol is "openai" if no prefix is specified.
-type ModelConfig struct {
- // Required fields
- ModelName string `json:"model_name"` // User-facing alias for the model
- Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
-
- // HTTP-based providers
- APIBase string `json:"api_base,omitempty"` // API endpoint URL
- APIKey string `json:"api_key"` // API authentication key
- Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
-
- // Special providers (CLI-based, OAuth, etc.)
- AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
- ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
- Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
-
- // Optional optimizations
- RPM int `json:"rpm,omitempty"` // Requests per minute limit
- MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
- RequestTimeout int `json:"request_timeout,omitempty"`
- ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
-}
-
-// Validate checks if the ModelConfig has all required fields.
-func (c *ModelConfig) Validate() error {
- if c.ModelName == "" {
- return fmt.Errorf("model_name is required")
- }
- if c.Model == "" {
- return fmt.Errorf("model is required")
- }
- return nil
-}
-
-type GatewayConfig struct {
- Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
- Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
-}
-
-type ToolDiscoveryConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
- TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
- MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
- UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
- UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
-}
-
-type ToolConfig struct {
- Enabled bool `json:"enabled" env:"ENABLED"`
-}
-
-type BraveConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
- APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
-}
-
-type TavilyConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
- APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
- BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
-}
-
-type DuckDuckGoConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
-}
-
-type PerplexityConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
- APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
-}
-
-type SearXNGConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
- BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
-}
-
-type GLMSearchConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
- APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
- BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
- // SearchEngine specifies the search backend: "search_std" (default),
- // "search_pro", "search_pro_sogou", or "search_pro_quark".
- SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
- MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
-}
-
-type WebToolsConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
- Brave BraveConfig ` json:"brave"`
- Tavily TavilyConfig ` json:"tavily"`
- DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
- Perplexity PerplexityConfig ` json:"perplexity"`
- SearXNG SearXNGConfig ` json:"searxng"`
- GLMSearch GLMSearchConfig ` json:"glm_search"`
- // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
- // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
- FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
-}
-
-type CronToolsConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
- ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
-}
-
-type ExecConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
- EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
- AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
- CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
- CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
- TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
-}
-
-type SkillsToolsConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
- Registries SkillsRegistriesConfig ` json:"registries"`
- MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
- SearchCache SearchCacheConfig ` json:"search_cache"`
-}
-
-type MediaCleanupConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
- MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
- Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
-}
-
-type ReadFileToolConfig struct {
- Enabled bool `json:"enabled"`
- MaxReadFileSize int `json:"max_read_file_size"`
-}
-
-type ToolsConfig struct {
- AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
- AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
- Web WebToolsConfig `json:"web"`
- Cron CronToolsConfig `json:"cron"`
- Exec ExecConfig `json:"exec"`
- Skills SkillsToolsConfig `json:"skills"`
- MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
- MCP MCPConfig `json:"mcp"`
- AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
- EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
- FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
- I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
- InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
- ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
- Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
- ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
- SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
- Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
- SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
- Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
- WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
- WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
-}
-
-type SearchCacheConfig struct {
- MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
- TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
-}
-
-type SkillsRegistriesConfig struct {
- ClawHub ClawHubRegistryConfig `json:"clawhub"`
-}
-
-type ClawHubRegistryConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
- BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
- AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
- SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
- SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
- DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
- Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
- MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
- MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
-}
-
-// MCPServerConfig defines configuration for a single MCP server
-type MCPServerConfig struct {
- // Enabled indicates whether this MCP server is active
- Enabled bool `json:"enabled"`
- // Command is the executable to run (e.g., "npx", "python", "/path/to/server")
- Command string `json:"command"`
- // Args are the arguments to pass to the command
- Args []string `json:"args,omitempty"`
- // Env are environment variables to set for the server process (stdio only)
- Env map[string]string `json:"env,omitempty"`
- // EnvFile is the path to a file containing environment variables (stdio only)
- EnvFile string `json:"env_file,omitempty"`
- // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
- Type string `json:"type,omitempty"`
- // URL is used for SSE/HTTP transport
- URL string `json:"url,omitempty"`
- // Headers are HTTP headers to send with requests (sse/http only)
- Headers map[string]string `json:"headers,omitempty"`
-}
-
-// MCPConfig defines configuration for all MCP servers
-type MCPConfig struct {
- ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
- Discovery ToolDiscoveryConfig ` json:"discovery"`
- // Servers is a map of server name to server configuration
- Servers map[string]MCPServerConfig `json:"servers,omitempty"`
-}
-
-func LoadConfig(path string) (*Config, error) {
- cfg := DefaultConfig()
-
- data, err := os.ReadFile(path)
- if err != nil {
- if os.IsNotExist(err) {
- return cfg, nil
- }
- return nil, err
- }
-
- // Pre-scan the JSON to check how many model_list entries the user provided.
- // Go's JSON decoder reuses existing slice backing-array elements rather than
- // zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
- // would silently inherit values from the DefaultConfig template at the same
- // index position. We only reset cfg.ModelList when the user actually provides
- // entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
- var tmp Config
- if err := json.Unmarshal(data, &tmp); err != nil {
- return nil, err
- }
- if len(tmp.ModelList) > 0 {
- cfg.ModelList = nil
- }
-
- if err := json.Unmarshal(data, cfg); err != nil {
- return nil, err
- }
-
- if err := env.Parse(cfg); err != nil {
- return nil, err
- }
-
- // Migrate legacy channel config fields to new unified structures
- cfg.migrateChannelConfigs()
-
- // Auto-migrate: if only legacy providers config exists, convert to model_list
- if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
- cfg.ModelList = ConvertProvidersToModelList(cfg)
- }
-
- // Validate model_list for uniqueness and required fields
- if err := cfg.ValidateModelList(); err != nil {
- return nil, err
- }
-
- return cfg, nil
-}
-
-func (c *Config) migrateChannelConfigs() {
- // Discord: mention_only -> group_trigger.mention_only
- if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
- c.Channels.Discord.GroupTrigger.MentionOnly = true
- }
-
- // OneBot: group_trigger_prefix -> group_trigger.prefixes
- if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
- len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
- c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
- }
-}
-
-func SaveConfig(path string, cfg *Config) error {
- data, err := json.MarshalIndent(cfg, "", " ")
- if err != nil {
- return err
- }
-
- // Use unified atomic write utility with explicit sync for flash storage reliability.
- return fileutil.WriteFileAtomic(path, data, 0o600)
-}
-
-func (c *Config) WorkspacePath() string {
- return expandHome(c.Agents.Defaults.Workspace)
-}
-
-func (c *Config) GetAPIKey() string {
- if c.Providers.OpenRouter.APIKey != "" {
- return c.Providers.OpenRouter.APIKey
- }
- if c.Providers.Anthropic.APIKey != "" {
- return c.Providers.Anthropic.APIKey
- }
- if c.Providers.OpenAI.APIKey != "" {
- return c.Providers.OpenAI.APIKey
- }
- if c.Providers.Gemini.APIKey != "" {
- return c.Providers.Gemini.APIKey
- }
- if c.Providers.Zhipu.APIKey != "" {
- return c.Providers.Zhipu.APIKey
- }
- if c.Providers.Groq.APIKey != "" {
- return c.Providers.Groq.APIKey
- }
- if c.Providers.VLLM.APIKey != "" {
- return c.Providers.VLLM.APIKey
- }
- if c.Providers.ShengSuanYun.APIKey != "" {
- return c.Providers.ShengSuanYun.APIKey
- }
- if c.Providers.Cerebras.APIKey != "" {
- return c.Providers.Cerebras.APIKey
- }
- return ""
-}
-
-func (c *Config) GetAPIBase() string {
- if c.Providers.OpenRouter.APIKey != "" {
- if c.Providers.OpenRouter.APIBase != "" {
- return c.Providers.OpenRouter.APIBase
- }
- return "https://openrouter.ai/api/v1"
- }
- if c.Providers.Zhipu.APIKey != "" {
- return c.Providers.Zhipu.APIBase
- }
- if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
- return c.Providers.VLLM.APIBase
- }
- return ""
-}
-
-func expandHome(path string) string {
- if path == "" {
- return path
- }
- if path[0] == '~' {
- home, _ := os.UserHomeDir()
- if len(path) > 1 && path[1] == '/' {
- return home + path[1:]
- }
- return home
- }
- return path
-}
-
-// GetModelConfig returns the ModelConfig for the given model name.
-// If multiple configs exist with the same model_name, it uses round-robin
-// selection for load balancing. Returns an error if the model is not found.
-func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
- matches := c.findMatches(modelName)
- if len(matches) == 0 {
- return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
- }
- if len(matches) == 1 {
- return &matches[0], nil
- }
-
- // Multiple configs - use round-robin for load balancing
- idx := rrCounter.Add(1) % uint64(len(matches))
- return &matches[idx], nil
-}
-
-// findMatches finds all ModelConfig entries with the given model_name.
-func (c *Config) findMatches(modelName string) []ModelConfig {
- var matches []ModelConfig
- for i := range c.ModelList {
- if c.ModelList[i].ModelName == modelName {
- matches = append(matches, c.ModelList[i])
- }
- }
- return matches
-}
-
-// HasProvidersConfig checks if any provider in the old providers config has configuration.
-func (c *Config) HasProvidersConfig() bool {
- return !c.Providers.IsEmpty()
-}
-
-// ValidateModelList validates all ModelConfig entries in the model_list.
-// It checks that each model config is valid.
-// Note: Multiple entries with the same model_name are allowed for load balancing.
-func (c *Config) ValidateModelList() error {
- for i := range c.ModelList {
- if err := c.ModelList[i].Validate(); err != nil {
- return fmt.Errorf("model_list[%d]: %w", i, err)
- }
- }
- return nil
-}
-
-func MergeAPIKeys(apiKey string, apiKeys []string) []string {
- seen := make(map[string]struct{})
- var all []string
-
- if k := strings.TrimSpace(apiKey); k != "" {
- if _, exists := seen[k]; !exists {
- seen[k] = struct{}{}
- all = append(all, k)
- }
- }
-
- for _, k := range apiKeys {
- if trimmed := strings.TrimSpace(k); trimmed != "" {
- if _, exists := seen[trimmed]; !exists {
- seen[trimmed] = struct{}{}
- all = append(all, trimmed)
- }
- }
- }
-
- return all
-}
-
-func (t *ToolsConfig) IsToolEnabled(name string) bool {
- switch name {
- case "web":
- return t.Web.Enabled
- case "cron":
- return t.Cron.Enabled
- case "exec":
- return t.Exec.Enabled
- case "skills":
- return t.Skills.Enabled
- case "media_cleanup":
- return t.MediaCleanup.Enabled
- case "append_file":
- return t.AppendFile.Enabled
- case "edit_file":
- return t.EditFile.Enabled
- case "find_skills":
- return t.FindSkills.Enabled
- case "i2c":
- return t.I2C.Enabled
- case "install_skill":
- return t.InstallSkill.Enabled
- case "list_dir":
- return t.ListDir.Enabled
- case "message":
- return t.Message.Enabled
- case "read_file":
- return t.ReadFile.Enabled
- case "spawn":
- return t.Spawn.Enabled
- case "spi":
- return t.SPI.Enabled
- case "subagent":
- return t.Subagent.Enabled
- case "web_fetch":
- return t.WebFetch.Enabled
- case "send_file":
- return t.SendFile.Enabled
- case "write_file":
- return t.WriteFile.Enabled
- case "mcp":
- return t.MCP.Enabled
- default:
- return true
- }
-}
+
+type Config struct {
+ Agents AgentsConfig `json:"agents"`
+ Bindings []AgentBinding `json:"bindings,omitempty"`
+ Session SessionConfig `json:"session,omitempty"`
+ Channels ChannelsConfig `json:"channels"`
+ Providers ProvidersConfig `json:"providers,omitempty"`
+ ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
+ Gateway GatewayConfig `json:"gateway"`
+ Tools ToolsConfig `json:"tools"`
+ Heartbeat HeartbeatConfig `json:"heartbeat"`
+ Devices DevicesConfig `json:"devices"`
+ Voice VoiceConfig `json:"voice"`
+ // BuildInfo contains build-time version information
+ BuildInfo BuildInfo `json:"build_info,omitempty"`
+}
+
+// BuildInfo contains build-time version information
+type BuildInfo struct {
+ Version string `json:"version"`
+ GitCommit string `json:"git_commit"`
+ BuildTime string `json:"build_time"`
+ GoVersion string `json:"go_version"`
+}
+
+// MarshalJSON implements custom JSON marshaling for Config
+// to omit providers section when empty and session when empty
+func (c Config) MarshalJSON() ([]byte, error) {
+ type Alias Config
+ aux := &struct {
+ Providers *ProvidersConfig `json:"providers,omitempty"`
+ Session *SessionConfig `json:"session,omitempty"`
+ *Alias
+ }{
+ Alias: (*Alias)(&c),
+ }
+
+ // Only include providers if not empty
+ if !c.Providers.IsEmpty() {
+ aux.Providers = &c.Providers
+ }
+
+ // Only include session if not empty
+ if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
+ aux.Session = &c.Session
+ }
+
+ return json.Marshal(aux)
+}
+
+type AgentsConfig struct {
+ Defaults AgentDefaults `json:"defaults"`
+ List []AgentConfig `json:"list,omitempty"`
+}
+
+// AgentModelConfig supports both string and structured model config.
+// String format: "gpt-4" (just primary, no fallbacks)
+// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
+type AgentModelConfig struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+}
+
+func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ m.Primary = s
+ m.Fallbacks = nil
+ return nil
+ }
+ type raw struct {
+ Primary string `json:"primary"`
+ Fallbacks []string `json:"fallbacks"`
+ }
+ var r raw
+ if err := json.Unmarshal(data, &r); err != nil {
+ return err
+ }
+ m.Primary = r.Primary
+ m.Fallbacks = r.Fallbacks
+ return nil
+}
+
+func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
+ if len(m.Fallbacks) == 0 && m.Primary != "" {
+ return json.Marshal(m.Primary)
+ }
+ type raw struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+ }
+ return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
+}
+
+type AgentConfig struct {
+ ID string `json:"id"`
+ Default bool `json:"default,omitempty"`
+ Name string `json:"name,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+ Skills []string `json:"skills,omitempty"`
+ Subagents *SubagentsConfig `json:"subagents,omitempty"`
+}
+
+type SubagentsConfig struct {
+ AllowAgents []string `json:"allow_agents,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+}
+
+type PeerMatch struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+}
+
+type BindingMatch struct {
+ Channel string `json:"channel"`
+ AccountID string `json:"account_id,omitempty"`
+ Peer *PeerMatch `json:"peer,omitempty"`
+ GuildID string `json:"guild_id,omitempty"`
+ TeamID string `json:"team_id,omitempty"`
+}
+
+type AgentBinding struct {
+ AgentID string `json:"agent_id"`
+ Match BindingMatch `json:"match"`
+}
+
+type SessionConfig struct {
+ DMScope string `json:"dm_scope,omitempty"`
+ IdentityLinks map[string][]string `json:"identity_links,omitempty"`
+}
+
+// RoutingConfig controls the intelligent model routing feature.
+// When enabled, each incoming message is scored against structural features
+// (message length, code blocks, tool call history, conversation depth, attachments).
+// Messages scoring below Threshold are sent to LightModel; all others use the
+// agent's primary model. This reduces cost and latency for simple tasks without
+// requiring any keyword matching — all scoring is language-agnostic.
+type RoutingConfig struct {
+ Enabled bool `json:"enabled"`
+ LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
+ Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
+}
+
+type AgentDefaults struct {
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
+ Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
+ SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
+ MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
+ Routing *RoutingConfig `json:"routing,omitempty"`
+}
+
+const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
+
+func (d *AgentDefaults) GetMaxMediaSize() int {
+ if d.MaxMediaSize > 0 {
+ return d.MaxMediaSize
+ }
+ return DefaultMaxMediaSize
+}
+
+// GetModelName returns the effective model name for the agent defaults.
+// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
+func (d *AgentDefaults) GetModelName() string {
+ if d.ModelName != "" {
+ return d.ModelName
+ }
+ return d.Model
+}
+
+type ChannelsConfig struct {
+ WhatsApp WhatsAppConfig `json:"whatsapp"`
+ Telegram TelegramConfig `json:"telegram"`
+ Feishu FeishuConfig `json:"feishu"`
+ Discord DiscordConfig `json:"discord"`
+ MaixCam MaixCamConfig `json:"maixcam"`
+ QQ QQConfig `json:"qq"`
+ DingTalk DingTalkConfig `json:"dingtalk"`
+ Slack SlackConfig `json:"slack"`
+ Matrix MatrixConfig `json:"matrix"`
+ LINE LINEConfig `json:"line"`
+ OneBot OneBotConfig `json:"onebot"`
+ WeCom WeComConfig `json:"wecom"`
+ WeComApp WeComAppConfig `json:"wecom_app"`
+ WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
+ Pico PicoConfig `json:"pico"`
+ IRC IRCConfig `json:"irc"`
+}
+
+// GroupTriggerConfig controls when the bot responds in group chats.
+type GroupTriggerConfig struct {
+ MentionOnly bool `json:"mention_only,omitempty"`
+ Prefixes []string `json:"prefixes,omitempty"`
+}
+
+// TypingConfig controls typing indicator behavior (Phase 10).
+type TypingConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+}
+
+// PlaceholderConfig controls placeholder message behavior (Phase 10).
+type PlaceholderConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+type WhatsAppConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
+ BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
+ UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
+ SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
+}
+
+type TelegramConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
+}
+
+type FeishuConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
+ RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
+}
+
+type DiscordConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
+ Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
+ MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
+}
+
+type MaixCamConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
+ Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
+ Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
+}
+
+type QQConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
+ SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
+}
+
+type DingTalkConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
+ ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
+ ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
+}
+
+type SlackConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
+}
+
+type MatrixConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
+ DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
+ JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
+ MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+}
+
+type LINEConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
+}
+
+type OneBotConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
+}
+
+type WeComConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
+ WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
+}
+
+type WeComAppConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
+ CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
+ CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
+ AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
+}
+
+type WeComAIBotConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
+ EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
+ ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
+ MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
+ WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
+}
+
+type PicoConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
+ AllowTokenQuery bool `json:"allow_token_query,omitempty"`
+ AllowOrigins []string `json:"allow_origins,omitempty"`
+ PingInterval int `json:"ping_interval,omitempty"`
+ ReadTimeout int `json:"read_timeout,omitempty"`
+ WriteTimeout int `json:"write_timeout,omitempty"`
+ MaxConnections int `json:"max_connections,omitempty"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+}
+
+type IRCConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
+ Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
+ TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
+ Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
+ User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
+ RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
+ Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
+ NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
+ SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
+ SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
+ Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
+ RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
+}
+
+type HeartbeatConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
+ Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
+}
+
+type DevicesConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
+ MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
+}
+
+type VoiceConfig struct {
+ EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
+}
+
+type ProvidersConfig struct {
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI OpenAIProviderConfig `json:"openai"`
+ LiteLLM ProviderConfig `json:"litellm"`
+ OpenRouter ProviderConfig `json:"openrouter"`
+ Groq ProviderConfig `json:"groq"`
+ Zhipu ProviderConfig `json:"zhipu"`
+ VLLM ProviderConfig `json:"vllm"`
+ Gemini ProviderConfig `json:"gemini"`
+ Nvidia ProviderConfig `json:"nvidia"`
+ Ollama ProviderConfig `json:"ollama"`
+ Moonshot ProviderConfig `json:"moonshot"`
+ ShengSuanYun ProviderConfig `json:"shengsuanyun"`
+ DeepSeek ProviderConfig `json:"deepseek"`
+ Cerebras ProviderConfig `json:"cerebras"`
+ Vivgrid ProviderConfig `json:"vivgrid"`
+ VolcEngine ProviderConfig `json:"volcengine"`
+ GitHubCopilot ProviderConfig `json:"github_copilot"`
+ Antigravity ProviderConfig `json:"antigravity"`
+ Qwen ProviderConfig `json:"qwen"`
+ Mistral ProviderConfig `json:"mistral"`
+ Avian ProviderConfig `json:"avian"`
+ Minimax ProviderConfig `json:"minimax"`
+ LongCat ProviderConfig `json:"longcat"`
+ ModelScope ProviderConfig `json:"modelscope"`
+}
+
+// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
+// Note: WebSearch is an optimization option and doesn't count as "non-empty"
+func (p ProvidersConfig) IsEmpty() bool {
+ return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
+ p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
+ p.LiteLLM.APIKey == "" && p.LiteLLM.APIBase == "" &&
+ p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
+ p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
+ p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
+ p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
+ p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
+ p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
+ p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
+ p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
+ p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
+ p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
+ p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
+ p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
+ p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
+ p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
+ p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
+ p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
+ p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
+ p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
+ p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
+ p.ModelScope.APIKey == "" && p.ModelScope.APIBase == ""
+}
+
+// MarshalJSON implements custom JSON marshaling for ProvidersConfig
+// to omit the entire section when empty
+func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
+ if p.IsEmpty() {
+ return []byte("null"), nil
+ }
+ type Alias ProvidersConfig
+ return json.Marshal((*Alias)(&p))
+}
+
+type ProviderConfig struct {
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
+}
+
+type OpenAIProviderConfig struct {
+ ProviderConfig
+ WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
+}
+
+// ModelConfig represents a model-centric provider configuration.
+// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
+// The model field uses protocol prefix format: [protocol/]model-identifier
+// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
+// Default protocol is "openai" if no prefix is specified.
+type ModelConfig struct {
+ // Required fields
+ ModelName string `json:"model_name"` // User-facing alias for the model
+ Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
+
+ // HTTP-based providers
+ APIBase string `json:"api_base,omitempty"` // API endpoint URL
+ APIKey string `json:"api_key"` // API authentication key
+ Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
+
+ // Special providers (CLI-based, OAuth, etc.)
+ AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
+ ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
+ Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
+
+ // Optional optimizations
+ RPM int `json:"rpm,omitempty"` // Requests per minute limit
+ MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
+ RequestTimeout int `json:"request_timeout,omitempty"`
+ ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
+}
+
+// Validate checks if the ModelConfig has all required fields.
+func (c *ModelConfig) Validate() error {
+ if c.ModelName == "" {
+ return fmt.Errorf("model_name is required")
+ }
+ if c.Model == "" {
+ return fmt.Errorf("model is required")
+ }
+ return nil
+}
+
+type GatewayConfig struct {
+ Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
+ Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
+}
+
+type ToolDiscoveryConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
+ TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
+ MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
+ UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
+ UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
+}
+
+type ToolConfig struct {
+ Enabled bool `json:"enabled" env:"ENABLED"`
+}
+
+type BraveConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
+ APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
+}
+
+type TavilyConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
+ APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
+type DuckDuckGoConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
+}
+
+type PerplexityConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
+type SearXNGConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
+}
+
+type GLMSearchConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
+ // SearchEngine specifies the search backend: "search_std" (default),
+ // "search_pro", "search_pro_sogou", or "search_pro_quark".
+ SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
+}
+
+type WebToolsConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
+ Brave BraveConfig ` json:"brave"`
+ Tavily TavilyConfig ` json:"tavily"`
+ DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
+ Perplexity PerplexityConfig ` json:"perplexity"`
+ SearXNG SearXNGConfig ` json:"searxng"`
+ GLMSearch GLMSearchConfig ` json:"glm_search"`
+ // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
+ // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
+ FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
+}
+
+type CronToolsConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
+ ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
+}
+
+type ExecConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
+ EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
+ AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
+ CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
+ CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
+ TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
+}
+
+type SkillsToolsConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
+ Registries SkillsRegistriesConfig ` json:"registries"`
+ Github SkillsGithubConfig ` json:"github"`
+ MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
+ SearchCache SearchCacheConfig ` json:"search_cache"`
+}
+
+type MediaCleanupConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
+ MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
+ Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
+}
+
+type ReadFileToolConfig struct {
+ Enabled bool `json:"enabled"`
+ MaxReadFileSize int `json:"max_read_file_size"`
+}
+
+type ToolsConfig struct {
+ AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
+ AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
+ Exec ExecConfig `json:"exec"`
+ Skills SkillsToolsConfig `json:"skills"`
+ MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
+ MCP MCPConfig `json:"mcp"`
+ AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
+ EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
+ FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
+ I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
+ InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
+ ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
+ Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
+ ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
+ SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
+ Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
+ SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
+ Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
+ WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
+ WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
+}
+
+type SearchCacheConfig struct {
+ MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
+ TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
+}
+
+type SkillsRegistriesConfig struct {
+ ClawHub ClawHubRegistryConfig `json:"clawhub"`
+}
+
+type SkillsGithubConfig struct {
+ Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
+}
+
+type ClawHubRegistryConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
+ AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
+ SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
+ SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
+ DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
+ Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
+ MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
+ MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
+}
+
+// MCPServerConfig defines configuration for a single MCP server
+type MCPServerConfig struct {
+ // Enabled indicates whether this MCP server is active
+ Enabled bool `json:"enabled"`
+ // Command is the executable to run (e.g., "npx", "python", "/path/to/server")
+ Command string `json:"command"`
+ // Args are the arguments to pass to the command
+ Args []string `json:"args,omitempty"`
+ // Env are environment variables to set for the server process (stdio only)
+ Env map[string]string `json:"env,omitempty"`
+ // EnvFile is the path to a file containing environment variables (stdio only)
+ EnvFile string `json:"env_file,omitempty"`
+ // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set)
+ Type string `json:"type,omitempty"`
+ // URL is used for SSE/HTTP transport
+ URL string `json:"url,omitempty"`
+ // Headers are HTTP headers to send with requests (sse/http only)
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+// MCPConfig defines configuration for all MCP servers
+type MCPConfig struct {
+ ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
+ Discovery ToolDiscoveryConfig ` json:"discovery"`
+ // Servers is a map of server name to server configuration
+ Servers map[string]MCPServerConfig `json:"servers,omitempty"`
+}
+
+func LoadConfig(path string) (*Config, error) {
+ cfg := DefaultConfig()
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return cfg, nil
+ }
+ return nil, err
+ }
+
+ // Pre-scan the JSON to check how many model_list entries the user provided.
+ // Go's JSON decoder reuses existing slice backing-array elements rather than
+ // zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
+ // would silently inherit values from the DefaultConfig template at the same
+ // index position. We only reset cfg.ModelList when the user actually provides
+ // entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
+ var tmp Config
+ if err := json.Unmarshal(data, &tmp); err != nil {
+ return nil, err
+ }
+ if len(tmp.ModelList) > 0 {
+ cfg.ModelList = nil
+ }
+
+ if err := json.Unmarshal(data, cfg); err != nil {
+ return nil, err
+ }
+
+ if err := env.Parse(cfg); err != nil {
+ return nil, err
+ }
+
+ // Migrate legacy channel config fields to new unified structures
+ cfg.migrateChannelConfigs()
+
+ // Auto-migrate: if only legacy providers config exists, convert to model_list
+ if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
+ cfg.ModelList = ConvertProvidersToModelList(cfg)
+ }
+
+ // Validate model_list for uniqueness and required fields
+ if err := cfg.ValidateModelList(); err != nil {
+ return nil, err
+ }
+
+ return cfg, nil
+}
+
+func (c *Config) migrateChannelConfigs() {
+ // Discord: mention_only -> group_trigger.mention_only
+ if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
+ c.Channels.Discord.GroupTrigger.MentionOnly = true
+ }
+
+ // OneBot: group_trigger_prefix -> group_trigger.prefixes
+ if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
+ len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
+ c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
+ }
+}
+
+func SaveConfig(path string, cfg *Config) error {
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return fileutil.WriteFileAtomic(path, data, 0o600)
+}
+
+func (c *Config) WorkspacePath() string {
+ return expandHome(c.Agents.Defaults.Workspace)
+}
+
+func (c *Config) GetAPIKey() string {
+ if c.Providers.OpenRouter.APIKey != "" {
+ return c.Providers.OpenRouter.APIKey
+ }
+ if c.Providers.Anthropic.APIKey != "" {
+ return c.Providers.Anthropic.APIKey
+ }
+ if c.Providers.OpenAI.APIKey != "" {
+ return c.Providers.OpenAI.APIKey
+ }
+ if c.Providers.Gemini.APIKey != "" {
+ return c.Providers.Gemini.APIKey
+ }
+ if c.Providers.Zhipu.APIKey != "" {
+ return c.Providers.Zhipu.APIKey
+ }
+ if c.Providers.Groq.APIKey != "" {
+ return c.Providers.Groq.APIKey
+ }
+ if c.Providers.VLLM.APIKey != "" {
+ return c.Providers.VLLM.APIKey
+ }
+ if c.Providers.ShengSuanYun.APIKey != "" {
+ return c.Providers.ShengSuanYun.APIKey
+ }
+ if c.Providers.Cerebras.APIKey != "" {
+ return c.Providers.Cerebras.APIKey
+ }
+ return ""
+}
+
+func (c *Config) GetAPIBase() string {
+ if c.Providers.OpenRouter.APIKey != "" {
+ if c.Providers.OpenRouter.APIBase != "" {
+ return c.Providers.OpenRouter.APIBase
+ }
+ return "https://openrouter.ai/api/v1"
+ }
+ if c.Providers.Zhipu.APIKey != "" {
+ return c.Providers.Zhipu.APIBase
+ }
+ if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
+ return c.Providers.VLLM.APIBase
+ }
+ return ""
+}
+
+func expandHome(path string) string {
+ if path == "" {
+ return path
+ }
+ if path[0] == '~' {
+ home, _ := os.UserHomeDir()
+ if len(path) > 1 && path[1] == '/' {
+ return home + path[1:]
+ }
+ return home
+ }
+ return path
+}
+
+// GetModelConfig returns the ModelConfig for the given model name.
+// If multiple configs exist with the same model_name, it uses round-robin
+// selection for load balancing. Returns an error if the model is not found.
+func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
+ matches := c.findMatches(modelName)
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
+ }
+ if len(matches) == 1 {
+ return &matches[0], nil
+ }
+
+ // Multiple configs - use round-robin for load balancing
+ idx := rrCounter.Add(1) % uint64(len(matches))
+ return &matches[idx], nil
+}
+
+// findMatches finds all ModelConfig entries with the given model_name.
+func (c *Config) findMatches(modelName string) []ModelConfig {
+ var matches []ModelConfig
+ for i := range c.ModelList {
+ if c.ModelList[i].ModelName == modelName {
+ matches = append(matches, c.ModelList[i])
+ }
+ }
+ return matches
+}
+
+// HasProvidersConfig checks if any provider in the old providers config has configuration.
+func (c *Config) HasProvidersConfig() bool {
+ return !c.Providers.IsEmpty()
+}
+
+// ValidateModelList validates all ModelConfig entries in the model_list.
+// It checks that each model config is valid.
+// Note: Multiple entries with the same model_name are allowed for load balancing.
+func (c *Config) ValidateModelList() error {
+ for i := range c.ModelList {
+ if err := c.ModelList[i].Validate(); err != nil {
+ return fmt.Errorf("model_list[%d]: %w", i, err)
+ }
+ }
+ return nil
+}
+
+func MergeAPIKeys(apiKey string, apiKeys []string) []string {
+ seen := make(map[string]struct{})
+ var all []string
+
+ if k := strings.TrimSpace(apiKey); k != "" {
+ if _, exists := seen[k]; !exists {
+ seen[k] = struct{}{}
+ all = append(all, k)
+ }
+ }
+
+ for _, k := range apiKeys {
+ if trimmed := strings.TrimSpace(k); trimmed != "" {
+ if _, exists := seen[trimmed]; !exists {
+ seen[trimmed] = struct{}{}
+ all = append(all, trimmed)
+ }
+ }
+ }
+
+ return all
+}
+
+func (t *ToolsConfig) IsToolEnabled(name string) bool {
+ switch name {
+ case "web":
+ return t.Web.Enabled
+ case "cron":
+ return t.Cron.Enabled
+ case "exec":
+ return t.Exec.Enabled
+ case "skills":
+ return t.Skills.Enabled
+ case "media_cleanup":
+ return t.MediaCleanup.Enabled
+ case "append_file":
+ return t.AppendFile.Enabled
+ case "edit_file":
+ return t.EditFile.Enabled
+ case "find_skills":
+ return t.FindSkills.Enabled
+ case "i2c":
+ return t.I2C.Enabled
+ case "install_skill":
+ return t.InstallSkill.Enabled
+ case "list_dir":
+ return t.ListDir.Enabled
+ case "message":
+ return t.Message.Enabled
+ case "read_file":
+ return t.ReadFile.Enabled
+ case "spawn":
+ return t.Spawn.Enabled
+ case "spi":
+ return t.SPI.Enabled
+ case "subagent":
+ return t.Subagent.Enabled
+ case "web_fetch":
+ return t.WebFetch.Enabled
+ case "send_file":
+ return t.SendFile.Enabled
+ case "write_file":
+ return t.WriteFile.Enabled
+ case "mcp":
+ return t.MCP.Enabled
+ default:
+ return true
+ }
+}
diff --git a/pkg/config/flexible_string_slice_test.go b/pkg/config/flexible_string_slice_test.go
index 018aa3478..a5bb1763d 100644
--- a/pkg/config/flexible_string_slice_test.go
+++ b/pkg/config/flexible_string_slice_test.go
@@ -1,19 +1,19 @@
-package config
-
-import (
- "encoding/json"
- "reflect"
- "testing"
-)
-
-func TestFlexibleStringSliceUnmarshalJSON_String(t *testing.T) {
- var got FlexibleStringSlice
- if err := json.Unmarshal([]byte(`"general, #ops,dev"`), &got); err != nil {
- t.Fatalf("Unmarshal() error = %v", err)
- }
-
- want := FlexibleStringSlice{"general", "#ops", "dev"}
- if !reflect.DeepEqual(got, want) {
- t.Fatalf("FlexibleStringSlice = %#v, want %#v", got, want)
- }
-}
+package config
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+)
+
+func TestFlexibleStringSliceUnmarshalJSON_String(t *testing.T) {
+ var got FlexibleStringSlice
+ if err := json.Unmarshal([]byte(`"general, #ops,dev"`), &got); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ want := FlexibleStringSlice{"general", "#ops", "dev"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("FlexibleStringSlice = %#v, want %#v", got, want)
+ }
+}
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index 392471094..29811e37e 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -1,126 +1,88 @@
-package api
-
-import (
- "bytes"
- "net/http"
- "net/http/httptest"
- "reflect"
- "testing"
-
- "github.com/sipeed/picoclaw/pkg/config"
-)
-
-func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
- configPath, cleanup := setupOAuthTestEnv(t)
- defer cleanup()
-
- h := NewHandler(configPath)
- mux := http.NewServeMux()
- h.RegisterRoutes(mux)
-
- req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace"
- }
- },
- "model_list": [
- {
- "model_name": "custom-default",
- "model": "openai/gpt-4o",
- "api_key": "sk-default"
- }
- ]
- }`))
- req.Header.Set("Content-Type", "application/json")
-
- rec := httptest.NewRecorder()
- mux.ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
- }
-
- cfg, err := config.LoadConfig(configPath)
- if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
- }
- if !cfg.Tools.Exec.AllowRemote {
- t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config")
- }
-}
-
-func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
- configPath, cleanup := setupOAuthTestEnv(t)
- defer cleanup()
-
- h := NewHandler(configPath)
- mux := http.NewServeMux()
- h.RegisterRoutes(mux)
-
- req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace"
- }
- },
- "model_list": [
- {
- "model_name": "custom-default",
- "model": "openai/gpt-4o",
- "api_key": "sk-default"
- }
- ]
- }`))
- req.Header.Set("Content-Type", "application/json")
-
- rec := httptest.NewRecorder()
- mux.ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
- }
-
- cfg, err := config.LoadConfig(configPath)
- if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
- }
- if got := cfg.ModelList[0].APIBase; got != "" {
- t.Fatalf("model_list[0].api_base = %q, want empty string", got)
- }
-}
-
-func TestHandlePatchConfig_AcceptsFlexibleStringSliceString(t *testing.T) {
- configPath, cleanup := setupOAuthTestEnv(t)
- defer cleanup()
-
- h := NewHandler(configPath)
- mux := http.NewServeMux()
- h.RegisterRoutes(mux)
-
- req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
- "channels": {
- "irc": {
- "enabled": true,
- "server": "irc.example.com:6667",
- "nick": "testbot",
- "channels": "general, #ops,dev"
- }
- }
- }`))
- req.Header.Set("Content-Type", "application/json")
-
- rec := httptest.NewRecorder()
- mux.ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
- }
-
- cfg, err := config.LoadConfig(configPath)
- if err != nil {
- t.Fatalf("LoadConfig() error = %v", err)
- }
-
- want := config.FlexibleStringSlice{"general", "#ops", "dev"}
- if !reflect.DeepEqual(cfg.Channels.IRC.Channels, want) {
- t.Fatalf("channels.irc.channels = %#v, want %#v", cfg.Channels.IRC.Channels, want)
- }
-}
+package api
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "custom-default",
+ "model": "openai/gpt-4o",
+ "api_key": "sk-default"
+ }
+ ]
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if !cfg.Tools.Exec.AllowRemote {
+ t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config")
+ }
+}
+
+func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "custom-default",
+ "model": "openai/gpt-4o",
+ "api_key": "sk-default"
+ }
+ ]
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ if got := cfg.ModelList[0].APIBase; got != "" {
+ t.Fatalf("model_list[0].api_base = %q, want empty string", got)
+ }
+}