feat: universal message chunking with rune support and sentence awareness
This commit is contained in:
parent
2fb2a733d4
commit
6e1b3c845d
10 changed files with 466 additions and 201 deletions
|
|
@ -120,12 +120,12 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
||||||
return fmt.Errorf("channel ID is empty")
|
return fmt.Errorf("channel ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
runes := []rune(msg.Content)
|
limit := c.config.MaxMessageLength
|
||||||
if len(runes) == 0 {
|
if limit <= 0 {
|
||||||
return nil
|
limit = 1900
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
|
chunks := utils.SplitMessage(msg.Content, limit)
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
for _, chunk := range chunks {
|
||||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -501,22 +501,47 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try reply token first (free, valid for ~25 seconds)
|
// Try reply token first (free, valid for ~25 seconds)
|
||||||
|
hasReplyToken := false
|
||||||
|
var tokenEntry replyTokenEntry
|
||||||
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
||||||
tokenEntry := entry.(replyTokenEntry)
|
tokenEntry = entry.(replyTokenEntry)
|
||||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
||||||
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
hasReplyToken = true
|
||||||
logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
|
|
||||||
"chat_id": msg.ChatID,
|
|
||||||
"quoted": quoteToken != "",
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limit := c.config.MaxMessageLength
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 4000
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := utils.SplitMessage(msg.Content, limit)
|
||||||
|
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
currentQuoteToken := ""
|
||||||
|
if i == 0 {
|
||||||
|
currentQuoteToken = quoteToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 && hasReplyToken {
|
||||||
|
if err := c.sendReply(ctx, tokenEntry.token, chunk, currentQuoteToken); err == nil {
|
||||||
|
logger.DebugCF("line", "Message chunk sent via Reply API", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"quoted": currentQuoteToken != "",
|
||||||
|
"chunk": i + 1,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logger.DebugC("line", "Reply API failed for chunk, falling back to Push API")
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to Push API
|
// Fall back to Push API
|
||||||
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
if err := c.sendPush(ctx, msg.ChatID, chunk, currentQuoteToken); err != nil {
|
||||||
|
return fmt.Errorf("failed to send chunk %d via push: %w", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTextMessage creates a text message object, optionally with quoteToken.
|
// buildTextMessage creates a text message object, optionally with quoteToken.
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,17 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
limit := c.config.MaxMessageLength
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 3000
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := utils.SplitMessage(msg.Content, limit)
|
||||||
|
|
||||||
|
acked := false
|
||||||
|
for i, chunk := range chunks {
|
||||||
opts := []slack.MsgOption{
|
opts := []slack.MsgOption{
|
||||||
slack.MsgOptionText(msg.Content, false),
|
slack.MsgOptionText(chunk, false),
|
||||||
}
|
}
|
||||||
|
|
||||||
if threadTS != "" {
|
if threadTS != "" {
|
||||||
|
|
@ -129,9 +138,10 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
|
|
||||||
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send slack message: %w", err)
|
return fmt.Errorf("failed to send slack chunk %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !acked {
|
||||||
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
||||||
msgRef := ref.(slackMessageRef)
|
msgRef := ref.(slackMessageRef)
|
||||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||||
|
|
@ -139,10 +149,14 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
Timestamp: msgRef.Timestamp,
|
Timestamp: msgRef.Timestamp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
acked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.DebugCF("slack", "Message sent", map[string]interface{}{
|
logger.DebugCF("slack", "Message sent", map[string]interface{}{
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
"thread_ts": threadTS,
|
"thread_ts": threadTS,
|
||||||
|
"chunks": len(chunks),
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -164,19 +164,29 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
c.stopThinking.Delete(msg.ChatID)
|
c.stopThinking.Delete(msg.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
limit := c.config.Channels.Telegram.MaxMessageLength
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 3500 // Leave space for HTML tags
|
||||||
|
}
|
||||||
|
|
||||||
// Try to edit placeholder
|
chunks := utils.SplitMessage(msg.Content, limit)
|
||||||
|
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
htmlContent := markdownToTelegramHTML(chunk)
|
||||||
|
|
||||||
|
// Try to edit placeholder only for the first chunk
|
||||||
|
if i == 0 {
|
||||||
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
||||||
c.placeholders.Delete(msg.ChatID)
|
c.placeholders.Delete(msg.ChatID)
|
||||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
||||||
editMsg.ParseMode = telego.ModeHTML
|
editMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
||||||
return nil
|
continue
|
||||||
}
|
}
|
||||||
// Fallback to new message if edit fails
|
// Fallback to new message if edit fails
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
@ -186,9 +196,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
tgMsg.ParseMode = ""
|
tgMsg.ParseMode = ""
|
||||||
|
tgMsg.Text = chunk // Use raw chunk if HTML fails
|
||||||
_, err = c.bot.SendMessage(ctx, tgMsg)
|
_, err = c.bot.SendMessage(ctx, tgMsg)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,17 @@ import (
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rrCounter is a global counter for round-robin load balancing across models.
|
|
||||||
var rrCounter atomic.Uint64
|
var rrCounter atomic.Uint64
|
||||||
|
|
||||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
|
||||||
// so allow_from can contain both "123" and 123.
|
|
||||||
type FlexibleStringSlice []string
|
type FlexibleStringSlice []string
|
||||||
|
|
||||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||||
// Try []string first
|
|
||||||
var ss []string
|
var ss []string
|
||||||
if err := json.Unmarshal(data, &ss); err == nil {
|
if err := json.Unmarshal(data, &ss); err == nil {
|
||||||
*f = ss
|
*f = ss
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try []interface{} to handle mixed types
|
|
||||||
var raw []interface{}
|
var raw []interface{}
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -52,15 +47,13 @@ type Config struct {
|
||||||
Session SessionConfig `json:"session,omitempty"`
|
Session SessionConfig `json:"session,omitempty"`
|
||||||
Channels ChannelsConfig `json:"channels"`
|
Channels ChannelsConfig `json:"channels"`
|
||||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||||
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
ModelList []ModelConfig `json:"model_list"`
|
||||||
Gateway GatewayConfig `json:"gateway"`
|
Gateway GatewayConfig `json:"gateway"`
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for Config
|
|
||||||
// to omit providers section when empty and session when empty
|
|
||||||
func (c Config) MarshalJSON() ([]byte, error) {
|
func (c Config) MarshalJSON() ([]byte, error) {
|
||||||
type Alias Config
|
type Alias Config
|
||||||
aux := &struct {
|
aux := &struct {
|
||||||
|
|
@ -71,12 +64,10 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||||
Alias: (*Alias)(&c),
|
Alias: (*Alias)(&c),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include providers if not empty
|
|
||||||
if !c.Providers.IsEmpty() {
|
if !c.Providers.IsEmpty() {
|
||||||
aux.Providers = &c.Providers
|
aux.Providers = &c.Providers
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include session if not empty
|
|
||||||
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
|
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
|
||||||
aux.Session = &c.Session
|
aux.Session = &c.Session
|
||||||
}
|
}
|
||||||
|
|
@ -89,9 +80,6 @@ type AgentsConfig struct {
|
||||||
List []AgentConfig `json:"list,omitempty"`
|
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 {
|
type AgentModelConfig struct {
|
||||||
Primary string `json:"primary,omitempty"`
|
Primary string `json:"primary,omitempty"`
|
||||||
Fallbacks []string `json:"fallbacks,omitempty"`
|
Fallbacks []string `json:"fallbacks,omitempty"`
|
||||||
|
|
@ -101,7 +89,6 @@ func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
|
||||||
var s string
|
var s string
|
||||||
if err := json.Unmarshal(data, &s); err == nil {
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
m.Primary = s
|
m.Primary = s
|
||||||
m.Fallbacks = nil
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
type raw struct {
|
type raw struct {
|
||||||
|
|
@ -196,6 +183,7 @@ type WhatsAppConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||||
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_WHATSAPP_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
|
|
@ -203,6 +191,7 @@ type TelegramConfig struct {
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
|
|
@ -212,6 +201,7 @@ type FeishuConfig struct {
|
||||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordConfig struct {
|
type DiscordConfig struct {
|
||||||
|
|
@ -219,6 +209,7 @@ type DiscordConfig struct {
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||||
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MaixCamConfig struct {
|
type MaixCamConfig struct {
|
||||||
|
|
@ -233,6 +224,7 @@ type QQConfig struct {
|
||||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DingTalkConfig struct {
|
type DingTalkConfig struct {
|
||||||
|
|
@ -240,6 +232,7 @@ type DingTalkConfig struct {
|
||||||
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||||
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlackConfig struct {
|
type SlackConfig struct {
|
||||||
|
|
@ -247,6 +240,7 @@ type SlackConfig struct {
|
||||||
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||||
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_SLACK_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LINEConfig struct {
|
type LINEConfig struct {
|
||||||
|
|
@ -257,6 +251,7 @@ type LINEConfig struct {
|
||||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_LINE_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneBotConfig struct {
|
type OneBotConfig struct {
|
||||||
|
|
@ -266,11 +261,12 @@ type OneBotConfig struct {
|
||||||
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
|
MaxMessageLength int `json:"max_message_length,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_MAX_MESSAGE_LENGTH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DevicesConfig struct {
|
type DevicesConfig struct {
|
||||||
|
|
@ -298,8 +294,6 @@ type ProvidersConfig struct {
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (p ProvidersConfig) IsEmpty() bool {
|
||||||
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
|
||||||
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
|
||||||
|
|
@ -320,8 +314,6 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
|
||||||
// to omit the entire section when empty
|
|
||||||
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
|
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
|
||||||
if p.IsEmpty() {
|
if p.IsEmpty() {
|
||||||
return []byte("null"), nil
|
return []byte("null"), nil
|
||||||
|
|
@ -335,7 +327,7 @@ type ProviderConfig struct {
|
||||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
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`
|
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAIProviderConfig struct {
|
type OpenAIProviderConfig struct {
|
||||||
|
|
@ -343,32 +335,19 @@ type OpenAIProviderConfig struct {
|
||||||
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
|
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 {
|
type ModelConfig struct {
|
||||||
// Required fields
|
ModelName string `json:"model_name"`
|
||||||
ModelName string `json:"model_name"` // User-facing alias for the model
|
Model string `json:"model"`
|
||||||
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
|
APIBase string `json:"api_base,omitempty"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
// HTTP-based providers
|
Proxy string `json:"proxy,omitempty"`
|
||||||
APIBase string `json:"api_base,omitempty"` // API endpoint URL
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
APIKey string `json:"api_key"` // API authentication key
|
ConnectMode string `json:"connect_mode,omitempty"`
|
||||||
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
|
Workspace string `json:"workspace,omitempty"`
|
||||||
|
RPM int `json:"rpm,omitempty"`
|
||||||
// Special providers (CLI-based, OAuth, etc.)
|
MaxTokensField string `json:"max_tokens_field,omitempty"`
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
|
||||||
func (c *ModelConfig) Validate() error {
|
func (c *ModelConfig) Validate() error {
|
||||||
if c.ModelName == "" {
|
if c.ModelName == "" {
|
||||||
return fmt.Errorf("model_name is required")
|
return fmt.Errorf("model_name is required")
|
||||||
|
|
@ -408,7 +387,7 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
|
|
@ -451,13 +430,10 @@ type ClawHubRegistryConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := &Config{}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -469,16 +445,6 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -561,9 +527,6 @@ func expandHome(path string) string {
|
||||||
return path
|
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) {
|
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||||
matches := c.findMatches(modelName)
|
matches := c.findMatches(modelName)
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
|
|
@ -573,12 +536,10 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||||
return &matches[0], nil
|
return &matches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple configs - use round-robin for load balancing
|
|
||||||
idx := rrCounter.Add(1) % uint64(len(matches))
|
idx := rrCounter.Add(1) % uint64(len(matches))
|
||||||
return &matches[idx], nil
|
return &matches[idx], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findMatches finds all ModelConfig entries with the given model_name.
|
|
||||||
func (c *Config) findMatches(modelName string) []ModelConfig {
|
func (c *Config) findMatches(modelName string) []ModelConfig {
|
||||||
var matches []ModelConfig
|
var matches []ModelConfig
|
||||||
for i := range c.ModelList {
|
for i := range c.ModelList {
|
||||||
|
|
@ -589,7 +550,6 @@ func (c *Config) findMatches(modelName string) []ModelConfig {
|
||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasProvidersConfig checks if any provider in the old providers config has configuration.
|
|
||||||
func (c *Config) HasProvidersConfig() bool {
|
func (c *Config) HasProvidersConfig() bool {
|
||||||
v := c.Providers
|
v := c.Providers
|
||||||
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
|
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
|
||||||
|
|
@ -611,9 +571,6 @@ func (c *Config) HasProvidersConfig() bool {
|
||||||
v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
|
v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (c *Config) ValidateModelList() error {
|
||||||
for i := range c.ModelList {
|
for i := range c.ModelList {
|
||||||
if err := c.ModelList[i].Validate(); err != nil {
|
if err := c.ModelList[i].Validate(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -7,29 +7,218 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPProvider struct {
|
type HTTPProvider struct {
|
||||||
delegate *openai_compat.Provider
|
apiKey string
|
||||||
|
apiBase string
|
||||||
|
maxTokensField string
|
||||||
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy != "" {
|
||||||
|
proxyURL, err := url.Parse(proxy)
|
||||||
|
if err == nil {
|
||||||
|
client.Transport = &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyURL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy),
|
apiKey: apiKey,
|
||||||
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
|
httpClient: client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
||||||
return &HTTPProvider{
|
provider := NewHTTPProvider(apiKey, apiBase, proxy)
|
||||||
delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
|
provider.maxTokensField = maxTokensField
|
||||||
}
|
return provider
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
return p.delegate.Chat(ctx, messages, tools, model, options)
|
if p.apiBase == "" {
|
||||||
|
return nil, fmt.Errorf("API base not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b)
|
||||||
|
if idx := strings.Index(model, "/"); idx != -1 {
|
||||||
|
prefix := model[:idx]
|
||||||
|
if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" {
|
||||||
|
model = model[idx+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-process messages loop removed - relying on ExtraContent persistence in Agent Loop.
|
||||||
|
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) > 0 {
|
||||||
|
requestBody["tools"] = tools
|
||||||
|
requestBody["tool_choice"] = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxTokens, ok := options["max_tokens"].(int); ok {
|
||||||
|
lowerModel := strings.ToLower(model)
|
||||||
|
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
||||||
|
requestBody["max_completion_tokens"] = maxTokens
|
||||||
|
} else {
|
||||||
|
requestBody["max_tokens"] = maxTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if temperature, ok := options["temperature"].(float64); ok {
|
||||||
|
lowerModel := strings.ToLower(model)
|
||||||
|
// Kimi k2 models only support temperature=1
|
||||||
|
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
||||||
|
requestBody["temperature"] = 1.0
|
||||||
|
} else {
|
||||||
|
requestBody["temperature"] = temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if p.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.parseResponse(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
|
var apiResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function *struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
ExtraContent *struct {
|
||||||
|
Google *struct {
|
||||||
|
ThoughtSignature string `json:"thought_signature"`
|
||||||
|
} `json:"google"`
|
||||||
|
} `json:"extra_content"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage *UsageInfo `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: "",
|
||||||
|
FinishReason: "stop",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := apiResponse.Choices[0]
|
||||||
|
|
||||||
|
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
arguments := make(map[string]interface{})
|
||||||
|
name := ""
|
||||||
|
|
||||||
|
// Extract thought_signature from Gemini/Google-specific extra content
|
||||||
|
thoughtSignature := ""
|
||||||
|
if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
|
||||||
|
thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.Type == "function" && tc.Function != nil {
|
||||||
|
name = tc.Function.Name
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
||||||
|
arguments["raw"] = tc.Function.Arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if tc.Function != nil {
|
||||||
|
// Legacy format without type field
|
||||||
|
name = tc.Function.Name
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
|
||||||
|
arguments["raw"] = tc.Function.Arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correctly map extracted ExtraContent to ToolCall struct
|
||||||
|
toolCall := ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: name,
|
||||||
|
Arguments: arguments,
|
||||||
|
ThoughtSignature: thoughtSignature, // Populating internal field for convenience
|
||||||
|
}
|
||||||
|
|
||||||
|
if thoughtSignature != "" {
|
||||||
|
toolCall.ExtraContent = &ExtraContent{
|
||||||
|
Google: &GoogleExtra{
|
||||||
|
ThoughtSignature: thoughtSignature,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
Content: choice.Message.Content,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
FinishReason: choice.FinishReason,
|
||||||
|
Usage: apiResponse.Usage,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HTTPProvider) GetDefaultModel() string {
|
func (p *HTTPProvider) GetDefaultModel() string {
|
||||||
|
|
|
||||||
|
|
@ -9,29 +9,31 @@ import (
|
||||||
|
|
||||||
type ToolCall = protocoltypes.ToolCall
|
type ToolCall = protocoltypes.ToolCall
|
||||||
type FunctionCall = protocoltypes.FunctionCall
|
type FunctionCall = protocoltypes.FunctionCall
|
||||||
|
type ExtraContent = protocoltypes.ExtraContent
|
||||||
|
type GoogleExtra = protocoltypes.GoogleExtra
|
||||||
type LLMResponse = protocoltypes.LLMResponse
|
type LLMResponse = protocoltypes.LLMResponse
|
||||||
type UsageInfo = protocoltypes.UsageInfo
|
type UsageInfo = protocoltypes.UsageInfo
|
||||||
type Message = protocoltypes.Message
|
type Message = protocoltypes.Message
|
||||||
type ToolDefinition = protocoltypes.ToolDefinition
|
type ToolDefinition = protocoltypes.ToolDefinition
|
||||||
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
|
||||||
type ExtraContent = protocoltypes.ExtraContent
|
|
||||||
type GoogleExtra = protocoltypes.GoogleExtra
|
|
||||||
|
|
||||||
type LLMProvider interface {
|
type LLMProvider interface {
|
||||||
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
|
||||||
GetDefaultModel() string
|
GetDefaultModel() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FailoverReason classifies why an LLM request failed for fallback decisions.
|
|
||||||
type FailoverReason string
|
type FailoverReason string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
FailoverAuth FailoverReason = "auth"
|
|
||||||
FailoverRateLimit FailoverReason = "rate_limit"
|
|
||||||
FailoverBilling FailoverReason = "billing"
|
|
||||||
FailoverTimeout FailoverReason = "timeout"
|
FailoverTimeout FailoverReason = "timeout"
|
||||||
|
FailoverStatus FailoverReason = "status"
|
||||||
|
FailoverEmpty FailoverReason = "empty"
|
||||||
FailoverFormat FailoverReason = "format"
|
FailoverFormat FailoverReason = "format"
|
||||||
FailoverOverloaded FailoverReason = "overloaded"
|
FailoverOverloaded FailoverReason = "overloaded"
|
||||||
|
FailoverAuth FailoverReason = "auth"
|
||||||
|
FailoverBilling FailoverReason = "billing"
|
||||||
|
FailoverRateLimit FailoverReason = "rate_limit"
|
||||||
|
FailoverContextWindow FailoverReason = "context_window"
|
||||||
FailoverUnknown FailoverReason = "unknown"
|
FailoverUnknown FailoverReason = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -256,10 +256,23 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
// Match absolute paths: Unix (starts with / after space/start/quotes) or Windows (X:\)
|
||||||
matches := pathPattern.FindAllString(cmd, -1)
|
// This regex is careful not to match scoped packages like @mastra/core
|
||||||
|
pathPattern := regexp.MustCompile(`(?:\s|^|["'|&;])(/[^\s\"'|&;]+)|([A-Za-z]:\\[^\\\"'|&;]+)`)
|
||||||
|
matches := pathPattern.FindAllStringSubmatch(cmd, -1)
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
raw := ""
|
||||||
|
if match[1] != "" {
|
||||||
|
raw = match[1] // Unix path
|
||||||
|
} else if match[2] != "" {
|
||||||
|
raw = match[2] // Windows path
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, raw := range matches {
|
|
||||||
p, err := filepath.Abs(raw)
|
p, err := filepath.Abs(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ import (
|
||||||
|
|
||||||
// SplitMessage splits long messages into chunks, preserving code block integrity.
|
// SplitMessage splits long messages into chunks, preserving code block integrity.
|
||||||
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
|
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
|
||||||
// but may extend to maxLen when needed.
|
// but may extend to maxLen when needed. It respects rune counts to ensure multi-byte
|
||||||
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
|
// characters (like emojis or CJK) are not split in half.
|
||||||
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
|
|
||||||
func SplitMessage(content string, maxLen int) []string {
|
func SplitMessage(content string, maxLen int) []string {
|
||||||
var messages []string
|
var messages []string
|
||||||
|
|
||||||
|
|
@ -21,9 +20,11 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
codeBlockBuffer = maxLen / 2
|
codeBlockBuffer = maxLen / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
for len(content) > 0 {
|
runes := []rune(content)
|
||||||
if len(content) <= maxLen {
|
|
||||||
messages = append(messages, content)
|
for len(runes) > 0 {
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
messages = append(messages, string(runes))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,56 +35,82 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find natural split point within the effective limit
|
// Find natural split point within the effective limit
|
||||||
msgEnd := findLastNewline(content[:effectiveLimit], 200)
|
msgEnd := findLastSentenceBoundaryRunes(runes[:effectiveLimit], 300)
|
||||||
if msgEnd <= 0 {
|
if msgEnd <= 0 {
|
||||||
msgEnd = findLastSpace(content[:effectiveLimit], 100)
|
msgEnd = findLastNewlineRunes(runes[:effectiveLimit], 200)
|
||||||
|
}
|
||||||
|
if msgEnd <= 0 {
|
||||||
|
msgEnd = findLastSpaceRunes(runes[:effectiveLimit], 100)
|
||||||
}
|
}
|
||||||
if msgEnd <= 0 {
|
if msgEnd <= 0 {
|
||||||
msgEnd = effectiveLimit
|
msgEnd = effectiveLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this would end with an incomplete code block
|
// Check if this would end with an incomplete code block
|
||||||
candidate := content[:msgEnd]
|
candidate := runes[:msgEnd]
|
||||||
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
unclosedIdx := findLastUnclosedCodeBlockRunes(candidate)
|
||||||
|
|
||||||
if unclosedIdx >= 0 {
|
if unclosedIdx >= 0 {
|
||||||
// Message would end with incomplete code block
|
// Message would end with incomplete code block
|
||||||
// Try to extend up to maxLen to include the closing ```
|
// Try to extend up to maxLen to include the closing ```
|
||||||
if len(content) > msgEnd {
|
if len(runes) > msgEnd {
|
||||||
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
closingIdx := findNextClosingCodeBlockRunes(runes, msgEnd)
|
||||||
if closingIdx > 0 && closingIdx <= maxLen {
|
if closingIdx > 0 && closingIdx <= maxLen {
|
||||||
// Extend to include the closing ```
|
// Extend to include the closing ```
|
||||||
msgEnd = closingIdx
|
msgEnd = closingIdx
|
||||||
} else {
|
} else {
|
||||||
// Code block is too long to fit in one chunk or missing closing fence.
|
// Code block is too long to fit in one chunk or missing closing fence.
|
||||||
// Try to split inside by injecting closing and reopening fences.
|
// Try to split inside by injecting closing and reopening fences.
|
||||||
headerEnd := strings.Index(content[unclosedIdx:], "\n")
|
|
||||||
|
// Find the header end (first newline after the opening ```)
|
||||||
|
headerEnd := -1
|
||||||
|
for i := unclosedIdx; i < len(runes); i++ {
|
||||||
|
if runes[i] == '\n' {
|
||||||
|
headerEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if headerEnd == -1 {
|
if headerEnd == -1 {
|
||||||
headerEnd = unclosedIdx + 3
|
headerEnd = unclosedIdx + 3
|
||||||
} else {
|
} else {
|
||||||
headerEnd += unclosedIdx
|
// include newline
|
||||||
|
headerEnd++
|
||||||
}
|
}
|
||||||
header := strings.TrimSpace(content[unclosedIdx:headerEnd])
|
|
||||||
|
header := strings.TrimSpace(string(runes[unclosedIdx:headerEnd]))
|
||||||
|
|
||||||
// If we have a reasonable amount of content after the header, split inside
|
// If we have a reasonable amount of content after the header, split inside
|
||||||
if msgEnd > headerEnd+20 {
|
if msgEnd > headerEnd+20 {
|
||||||
// Find a better split point closer to maxLen
|
// Find a better split point closer to maxLen
|
||||||
innerLimit := maxLen - 5 // Leave room for "\n```"
|
innerLimit := maxLen - 5 // Leave room for "\n```"
|
||||||
betterEnd := findLastNewline(content[:innerLimit], 200)
|
betterEnd := findLastSentenceBoundaryRunes(runes[:innerLimit], 300)
|
||||||
|
if betterEnd <= headerEnd {
|
||||||
|
betterEnd = findLastNewlineRunes(runes[:innerLimit], 200)
|
||||||
|
}
|
||||||
|
|
||||||
if betterEnd > headerEnd {
|
if betterEnd > headerEnd {
|
||||||
msgEnd = betterEnd
|
msgEnd = betterEnd
|
||||||
} else {
|
} else {
|
||||||
msgEnd = innerLimit
|
msgEnd = innerLimit
|
||||||
}
|
}
|
||||||
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
|
||||||
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
chunkStr := string(runes[:msgEnd])
|
||||||
|
messages = append(messages, strings.TrimRight(chunkStr, " \t\n\r")+"\n```")
|
||||||
|
|
||||||
|
nextChunkStart := string(runes[msgEnd:])
|
||||||
|
content = strings.TrimSpace(header + "\n" + nextChunkStart)
|
||||||
|
runes = []rune(content)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, try to split before the code block starts
|
// Otherwise, try to split before the code block starts
|
||||||
newEnd := findLastNewline(content[:unclosedIdx], 200)
|
newEnd := findLastSentenceBoundaryRunes(runes[:unclosedIdx], 300)
|
||||||
if newEnd <= 0 {
|
if newEnd <= 0 {
|
||||||
newEnd = findLastSpace(content[:unclosedIdx], 100)
|
newEnd = findLastNewlineRunes(runes[:unclosedIdx], 200)
|
||||||
|
}
|
||||||
|
if newEnd <= 0 {
|
||||||
|
newEnd = findLastSpaceRunes(runes[:unclosedIdx], 100)
|
||||||
}
|
}
|
||||||
if newEnd > 0 {
|
if newEnd > 0 {
|
||||||
msgEnd = newEnd
|
msgEnd = newEnd
|
||||||
|
|
@ -93,8 +120,12 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
msgEnd = unclosedIdx
|
msgEnd = unclosedIdx
|
||||||
} else {
|
} else {
|
||||||
msgEnd = maxLen - 5
|
msgEnd = maxLen - 5
|
||||||
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
|
chunkStr := string(runes[:msgEnd])
|
||||||
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
|
messages = append(messages, strings.TrimRight(chunkStr, " \t\n\r")+"\n```")
|
||||||
|
|
||||||
|
nextChunkStart := string(runes[msgEnd:])
|
||||||
|
content = strings.TrimSpace(header + "\n" + nextChunkStart)
|
||||||
|
runes = []rune(content)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,21 +137,22 @@ func SplitMessage(content string, maxLen int) []string {
|
||||||
msgEnd = effectiveLimit
|
msgEnd = effectiveLimit
|
||||||
}
|
}
|
||||||
|
|
||||||
messages = append(messages, content[:msgEnd])
|
messages = append(messages, string(runes[:msgEnd]))
|
||||||
content = strings.TrimSpace(content[msgEnd:])
|
nextContent := strings.TrimSpace(string(runes[msgEnd:]))
|
||||||
|
runes = []rune(nextContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
// findLastUnclosedCodeBlockRunes finds the last opening ``` that doesn't have a closing ```
|
||||||
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
||||||
func findLastUnclosedCodeBlock(text string) int {
|
func findLastUnclosedCodeBlockRunes(runes []rune) int {
|
||||||
inCodeBlock := false
|
inCodeBlock := false
|
||||||
lastOpenIdx := -1
|
lastOpenIdx := -1
|
||||||
|
|
||||||
for i := 0; i < len(text); i++ {
|
for i := 0; i < len(runes); i++ {
|
||||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
||||||
// Toggle code block state on each fence
|
// Toggle code block state on each fence
|
||||||
if !inCodeBlock {
|
if !inCodeBlock {
|
||||||
// Entering a code block: record this opening fence
|
// Entering a code block: record this opening fence
|
||||||
|
|
@ -137,43 +169,62 @@ func findLastUnclosedCodeBlock(text string) int {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
// findNextClosingCodeBlockRunes finds the next closing ``` starting from a position
|
||||||
// Returns the position after the closing ``` or -1 if not found
|
// Returns the position after the closing ``` or -1 if not found
|
||||||
func findNextClosingCodeBlock(text string, startIdx int) int {
|
func findNextClosingCodeBlockRunes(runes []rune, startIdx int) int {
|
||||||
for i := startIdx; i < len(text); i++ {
|
for i := startIdx; i < len(runes); i++ {
|
||||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
|
||||||
return i + 3
|
return i + 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// findLastNewline finds the last newline character within the last N characters
|
// findLastNewlineRunes finds the last newline character within the last N characters
|
||||||
// Returns the position of the newline or -1 if not found
|
// Returns the position of the newline or -1 if not found
|
||||||
func findLastNewline(s string, searchWindow int) int {
|
func findLastNewlineRunes(runes []rune, searchWindow int) int {
|
||||||
searchStart := len(s) - searchWindow
|
searchStart := len(runes) - searchWindow
|
||||||
if searchStart < 0 {
|
if searchStart < 0 {
|
||||||
searchStart = 0
|
searchStart = 0
|
||||||
}
|
}
|
||||||
for i := len(s) - 1; i >= searchStart; i-- {
|
for i := len(runes) - 1; i >= searchStart; i-- {
|
||||||
if s[i] == '\n' {
|
if runes[i] == '\n' {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// findLastSpace finds the last space character within the last N characters
|
// findLastSpaceRunes finds the last space character within the last N characters
|
||||||
// Returns the position of the space or -1 if not found
|
// Returns the position of the space or -1 if not found
|
||||||
func findLastSpace(s string, searchWindow int) int {
|
func findLastSpaceRunes(runes []rune, searchWindow int) int {
|
||||||
searchStart := len(s) - searchWindow
|
searchStart := len(runes) - searchWindow
|
||||||
if searchStart < 0 {
|
if searchStart < 0 {
|
||||||
searchStart = 0
|
searchStart = 0
|
||||||
}
|
}
|
||||||
for i := len(s) - 1; i >= searchStart; i-- {
|
for i := len(runes) - 1; i >= searchStart; i-- {
|
||||||
if s[i] == ' ' || s[i] == '\t' {
|
if runes[i] == ' ' || runes[i] == '\t' {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findLastSentenceBoundaryRunes finds the last sentence-ending punctuation
|
||||||
|
// Returns the position after the punctuation or -1 if not found
|
||||||
|
func findLastSentenceBoundaryRunes(runes []rune, searchWindow int) int {
|
||||||
|
searchStart := len(runes) - searchWindow
|
||||||
|
if searchStart < 0 {
|
||||||
|
searchStart = 0
|
||||||
|
}
|
||||||
|
for i := len(runes) - 1; i >= searchStart; i-- {
|
||||||
|
switch runes[i] {
|
||||||
|
case '.', '!', '?', '。', '!', '?':
|
||||||
|
// Ensure it's the end of a sentence (followed by space, newline, or end of string)
|
||||||
|
if i == len(runes)-1 || runes[i+1] == ' ' || runes[i+1] == '\n' || runes[i+1] == '\t' {
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func TestSplitMessage(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Preserve Unicode characters",
|
name: "Preserve Unicode characters",
|
||||||
content: strings.Repeat("\u4e16", 1000), // 3000 bytes
|
content: strings.Repeat("\u4e16", 2500), // 2500 runes
|
||||||
maxLen: 2000,
|
maxLen: 2000,
|
||||||
expectChunks: 2,
|
expectChunks: 2,
|
||||||
checkContent: func(t *testing.T, chunks []string) {
|
checkContent: func(t *testing.T, chunks []string) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue