picoclaw/pkg/providers/openai_compat/provider.go

859 lines
22 KiB
Go
Raw Normal View History

package openai_compat
import (
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
"io"
"maps"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers/common"
"github.com/sipeed/picoclaw/pkg/providers/messageutil"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
)
2026-02-20 18:03:11 +00:00
type (
ToolCall = protocoltypes.ToolCall
FunctionCall = protocoltypes.FunctionCall
LLMResponse = protocoltypes.LLMResponse
StreamChunk = protocoltypes.StreamChunk
2026-02-20 18:03:11 +00:00
UsageInfo = protocoltypes.UsageInfo
Message = protocoltypes.Message
ToolDefinition = protocoltypes.ToolDefinition
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
ReasoningDetail = protocoltypes.ReasoningDetail
2026-02-20 18:03:11 +00:00
)
type Provider struct {
apiKey string
apiBase string
providerName string
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client
extraBody map[string]any // Additional fields to inject into request body
customHeaders map[string]string
userAgent string
}
type Option func(*Provider)
const (
defaultRequestTimeout = common.DefaultRequestTimeout
defaultStreamingReadIdleTimeout = 5 * time.Minute
)
var stripModelPrefixProviders = map[string]struct{}{
"litellm": {},
"nearai": {},
"venice": {},
"moonshot": {},
"nvidia": {},
"groq": {},
"ollama": {},
"deepseek": {},
"google": {},
"openrouter": {},
"siliconflow": {},
"zhipu": {},
"mistral": {},
"vivgrid": {},
"minimax": {},
"novita": {},
"lmstudio": {},
}
func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) {
p.maxTokensField = maxTokensField
}
}
func WithUserAgent(userAgent string) Option {
return func(p *Provider) {
p.userAgent = userAgent
}
}
func WithRequestTimeout(timeout time.Duration) Option {
return func(p *Provider) {
if timeout > 0 {
p.httpClient.Timeout = timeout
}
}
}
func WithExtraBody(extraBody map[string]any) Option {
return func(p *Provider) {
p.extraBody = extraBody
}
}
func WithCustomHeaders(customHeaders map[string]string) Option {
return func(p *Provider) {
p.customHeaders = customHeaders
}
}
func WithProviderName(providerName string) Option {
return func(p *Provider) {
p.providerName = strings.ToLower(strings.TrimSpace(providerName))
}
}
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
p := &Provider{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
httpClient: common.NewHTTPClient(proxy),
}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
}
func NewProviderWithMaxTokensFieldAndTimeout(
apiKey, apiBase, proxy, maxTokensField string,
requestTimeoutSeconds int,
) *Provider {
return NewProvider(
apiKey,
apiBase,
proxy,
WithMaxTokensField(maxTokensField),
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
)
}
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
// buildRequestBody constructs the common request body for Chat and ChatStream.
func (p *Provider) buildRequestBody(
messages []Message, tools []ToolDefinition, model string, options map[string]any,
) map[string]any {
model = normalizeModel(model, p.apiBase)
2026-02-20 18:03:11 +00:00
requestBody := map[string]any{
"model": model,
"messages": common.SerializeMessages(p.prepareMessagesForRequest(messages)),
}
// When fallback uses a different provider (e.g. DeepSeek), that provider must not inject web_search_preview.
nativeSearch, _ := options["native_search"].(bool)
nativeSearch = nativeSearch && isNativeSearchHost(p.apiBase)
if len(tools) > 0 || nativeSearch {
requestBody["tools"] = buildToolsList(tools, nativeSearch)
requestBody["tool_choice"] = "auto"
}
if maxTokens, ok := common.AsInt(options["max_tokens"]); ok {
fieldName := p.maxTokensField
if fieldName == "" {
lowerModel := strings.ToLower(model)
2026-02-20 18:03:11 +00:00
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
strings.Contains(lowerModel, "gpt-5") {
fieldName = "max_completion_tokens"
} else {
fieldName = "max_tokens"
}
}
requestBody[fieldName] = maxTokens
}
if temperature, ok := common.AsFloat(options["temperature"]); ok {
lowerModel := strings.ToLower(model)
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0
} else {
requestBody["temperature"] = temperature
}
}
fix: cache system prompt with mtime-based auto-invalidation (#607) Avoid rebuilding the entire system prompt on every BuildMessages() call by caching the static portion (identity, bootstrap, skills summary, memory) and only recomputing it when workspace source files change. Key changes: - ContextBuilder caches the static prompt behind an RWMutex with double-checked locking. Source file changes are detected via cheap os.Stat mtime checks so no explicit invalidation is needed. - Track file existence at cache time (existedAtCache map) so that newly created or deleted bootstrap/memory files also trigger a rebuild — the old modifiedSince() silently returned false on os.IsNotExist. - Walk the skills directory recursively with filepath.WalkDir to catch content-only edits at any nesting depth; directory mtime alone misses in-place file modifications on most filesystems. - ToolRegistry.sortedToolNames() sorts tool names before iteration, ensuring deterministic tool definition order across calls — a prerequisite for LLM-side prefix/KV cache reuse. - Merge all context (static + dynamic + summary) into a single system message for provider compatibility: the Anthropic adapter extracts messages[0] as the top-level system parameter, and Codex reads only the first system message as instructions. - Fix a data race in BuildMessages() where cachedSystemPrompt was read without holding the lock in a debug log statement. - Add tests: single system message invariant, mtime auto-invalidation, new-file creation detection, skill file content change, explicit InvalidateCache, cache stability, concurrent access (20 goroutines x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
// Prompt caching: pass a stable cache key so OpenAI can bucket requests
// with the same key and reuse prefix KV cache across calls.
// Prompt caching is only supported by OpenAI-native endpoints.
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
// Non-OpenAI providers reject unknown fields with 422 errors.
fix: cache system prompt with mtime-based auto-invalidation (#607) Avoid rebuilding the entire system prompt on every BuildMessages() call by caching the static portion (identity, bootstrap, skills summary, memory) and only recomputing it when workspace source files change. Key changes: - ContextBuilder caches the static prompt behind an RWMutex with double-checked locking. Source file changes are detected via cheap os.Stat mtime checks so no explicit invalidation is needed. - Track file existence at cache time (existedAtCache map) so that newly created or deleted bootstrap/memory files also trigger a rebuild — the old modifiedSince() silently returned false on os.IsNotExist. - Walk the skills directory recursively with filepath.WalkDir to catch content-only edits at any nesting depth; directory mtime alone misses in-place file modifications on most filesystems. - ToolRegistry.sortedToolNames() sorts tool names before iteration, ensuring deterministic tool definition order across calls — a prerequisite for LLM-side prefix/KV cache reuse. - Merge all context (static + dynamic + summary) into a single system message for provider compatibility: the Anthropic adapter extracts messages[0] as the top-level system parameter, and Codex reads only the first system message as instructions. - Fix a data race in BuildMessages() where cachedSystemPrompt was read without holding the lock in a debug log statement. - Add tests: single system message invariant, mtime auto-invalidation, new-file creation detection, skill file content change, explicit InvalidateCache, cache stability, concurrent access (20 goroutines x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if supportsPromptCacheKey(p.apiBase) {
requestBody["prompt_cache_key"] = cacheKey
}
fix: cache system prompt with mtime-based auto-invalidation (#607) Avoid rebuilding the entire system prompt on every BuildMessages() call by caching the static portion (identity, bootstrap, skills summary, memory) and only recomputing it when workspace source files change. Key changes: - ContextBuilder caches the static prompt behind an RWMutex with double-checked locking. Source file changes are detected via cheap os.Stat mtime checks so no explicit invalidation is needed. - Track file existence at cache time (existedAtCache map) so that newly created or deleted bootstrap/memory files also trigger a rebuild — the old modifiedSince() silently returned false on os.IsNotExist. - Walk the skills directory recursively with filepath.WalkDir to catch content-only edits at any nesting depth; directory mtime alone misses in-place file modifications on most filesystems. - ToolRegistry.sortedToolNames() sorts tool names before iteration, ensuring deterministic tool definition order across calls — a prerequisite for LLM-side prefix/KV cache reuse. - Merge all context (static + dynamic + summary) into a single system message for provider compatibility: the Anthropic adapter extracts messages[0] as the top-level system parameter, and Codex reads only the first system message as instructions. - Fix a data race in BuildMessages() where cachedSystemPrompt was read without holding the lock in a debug log statement. - Add tests: single system message invariant, mtime auto-invalidation, new-file creation detection, skill file content change, explicit InvalidateCache, cache stability, concurrent access (20 goroutines x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
}
p.applyThinkingControl(requestBody, model, options)
// Merge extra body fields configured per-provider/model.
// These are injected last so they take precedence over defaults.
maps.Copy(requestBody, p.extraBody)
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
return requestBody
}
func (p *Provider) applyThinkingControl(requestBody map[string]any, model string, options map[string]any) {
level, ok := normalizedThinkingLevel(options)
if !ok {
return
}
if p.SupportsThinking() {
p.applyDeepSeekThinkingControl(requestBody, level)
return
}
if level != "off" {
return
}
switch p.thinkingControlKind(model) {
case "thinking_type":
requestBody["thinking"] = map[string]any{"type": "disabled"}
case "enable_thinking":
requestBody["enable_thinking"] = false
}
}
func (p *Provider) applyDeepSeekThinkingControl(requestBody map[string]any, level string) {
switch level {
case "off":
requestBody["thinking"] = map[string]any{"type": "disabled"}
case "low", "medium", "high":
requestBody["thinking"] = map[string]any{"type": "enabled"}
requestBody["reasoning_effort"] = "high"
case "xhigh":
requestBody["thinking"] = map[string]any{"type": "enabled"}
requestBody["reasoning_effort"] = "max"
case "adaptive":
logger.WarnCF("provider.openai_compat",
`DeepSeek does not support thinking_level="adaptive"; using provider default thinking behavior`,
map[string]any{
"provider": p.providerName,
"api_base": p.apiBase,
"thinking_level": level,
},
)
}
}
func normalizedThinkingLevel(options map[string]any) (string, bool) {
raw, ok := options["thinking_level"].(string)
if !ok {
return "", false
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "off", "low", "medium", "high", "xhigh", "adaptive":
return strings.ToLower(strings.TrimSpace(raw)), true
default:
return "", false
}
}
func (p *Provider) thinkingControlKind(model string) string {
providerName := strings.ToLower(strings.TrimSpace(p.providerName))
lowerModel := strings.ToLower(strings.TrimSpace(model))
switch providerName {
case "volcengine":
return "thinking_type"
case "zhipu", "zai":
return "thinking_type"
case "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us":
return "enable_thinking"
case "modelscope":
if strings.Contains(lowerModel, "qwen") {
return "enable_thinking"
}
}
if providerName == "openai" || providerName == "" {
if isVolcengineHost(p.apiBase) || strings.Contains(lowerModel, "doubao") {
return "thinking_type"
}
if isDashScopeHost(p.apiBase) || strings.Contains(lowerModel, "qwen") {
return "enable_thinking"
}
}
return ""
}
func isVolcengineHost(apiBase string) bool {
host := normalizedHostname(apiBase)
return host == "volcengine.com" || strings.HasSuffix(host, ".volcengine.com") ||
host == "volces.com" || strings.HasSuffix(host, ".volces.com")
}
func isDashScopeHost(apiBase string) bool {
host := normalizedHostname(apiBase)
return host == "dashscope.aliyuncs.com" || strings.HasSuffix(host, ".dashscope.aliyuncs.com")
}
func normalizedHostname(rawURL string) string {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
func (p *Provider) applyCustomHeaders(req *http.Request) {
for k, v := range p.customHeaders {
if strings.TrimSpace(k) == "" {
continue
}
req.Header.Set(k, v)
}
}
func (p *Provider) SetProviderName(providerName string) {
p.providerName = strings.ToLower(strings.TrimSpace(providerName))
}
func (p *Provider) SupportsThinking() bool {
return strings.EqualFold(strings.TrimSpace(p.providerName), "deepseek") || isDeepSeekHost(p.apiBase)
}
func (p *Provider) prepareMessagesForRequest(messages []Message) []Message {
if len(messages) == 0 {
return nil
}
if p.requiresToolRoundReasoningReplay() {
return filterReasoningReplayMessages(messages)
}
return stripReasoningMessages(messages)
}
func (p *Provider) requiresToolRoundReasoningReplay() bool {
return p.providerName == "deepseek" ||
p.providerName == "mimo" ||
isDeepSeekHost(p.apiBase) ||
isMiMoHost(p.apiBase)
}
func isDeepSeekHost(apiBase string) bool {
parsed, err := url.Parse(strings.TrimSpace(apiBase))
if err != nil {
return false
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
return host == "deepseek.com" || strings.HasSuffix(host, ".deepseek.com")
}
func isMiMoHost(apiBase string) bool {
parsed, err := url.Parse(strings.TrimSpace(apiBase))
if err != nil {
return false
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
return host == "xiaomimimo.com" || strings.HasSuffix(host, ".xiaomimimo.com")
}
func filterReasoningReplayMessages(messages []Message) []Message {
out := make([]Message, 0, len(messages))
start := 0
flush := func(end int) {
if end <= start {
return
}
out = append(out, filterReasoningReplayTurn(messages[start:end])...)
start = end
}
for i := 1; i < len(messages); i++ {
if messages[i].Role == "user" {
flush(i)
}
}
flush(len(messages))
return out
}
func filterReasoningReplayTurn(messages []Message) []Message {
hasToolInteraction := false
for _, msg := range messages {
if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) {
hasToolInteraction = true
break
}
}
out := make([]Message, 0, len(messages))
for _, msg := range messages {
if messageutil.IsTransientAssistantThoughtMessage(msg) {
continue
}
cloned := msg
// DeepSeek and MiMo only require reasoning_content replay for turns
// that participate in a tool interaction round. For plain assistant
// turns between two user messages, the reasoning trace is ignored on
// replay, so we strip it here.
if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction {
cloned.ReasoningContent = ""
}
if assistantMessageEmpty(cloned) {
continue
}
out = append(out, cloned)
}
return out
}
func stripReasoningMessages(messages []Message) []Message {
out := make([]Message, 0, len(messages))
for _, msg := range messages {
if messageutil.IsTransientAssistantThoughtMessage(msg) {
continue
}
cloned := msg
cloned.ReasoningContent = ""
if assistantMessageEmpty(cloned) {
continue
}
out = append(out, cloned)
}
return out
}
func assistantMessageEmpty(msg Message) bool {
return msg.Role == "assistant" &&
strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) == "" &&
len(msg.ToolCalls) == 0 &&
len(msg.Media) == 0 &&
len(msg.Attachments) == 0 &&
strings.TrimSpace(msg.ToolCallID) == ""
}
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
func (p *Provider) Chat(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
requestBody := p.buildRequestBody(messages, tools, model, options)
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.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
}
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
p.applyCustomHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, common.HandleErrorResponse(resp, p.apiBase)
}
return common.ReadAndParseResponse(resp, p.apiBase)
fix: cache system prompt with mtime-based auto-invalidation (#607) Avoid rebuilding the entire system prompt on every BuildMessages() call by caching the static portion (identity, bootstrap, skills summary, memory) and only recomputing it when workspace source files change. Key changes: - ContextBuilder caches the static prompt behind an RWMutex with double-checked locking. Source file changes are detected via cheap os.Stat mtime checks so no explicit invalidation is needed. - Track file existence at cache time (existedAtCache map) so that newly created or deleted bootstrap/memory files also trigger a rebuild — the old modifiedSince() silently returned false on os.IsNotExist. - Walk the skills directory recursively with filepath.WalkDir to catch content-only edits at any nesting depth; directory mtime alone misses in-place file modifications on most filesystems. - ToolRegistry.sortedToolNames() sorts tool names before iteration, ensuring deterministic tool definition order across calls — a prerequisite for LLM-side prefix/KV cache reuse. - Merge all context (static + dynamic + summary) into a single system message for provider compatibility: the Anthropic adapter extracts messages[0] as the top-level system parameter, and Codex reads only the first system message as instructions. - Fix a data race in BuildMessages() where cachedSystemPrompt was read without holding the lock in a debug log statement. - Add tests: single system message invariant, mtime auto-invalidation, new-file creation detection, skill file content change, explicit InvalidateCache, cache stability, concurrent access (20 goroutines x 50 iterations, passes go test -race), and a benchmark.
2026-02-25 02:34:54 +00:00
}
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
// ChatStream implements streaming via OpenAI-compatible SSE (stream: true).
// onChunk receives the accumulated text so far on each text delta.
func (p *Provider) ChatStream(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
onChunk func(accumulated string),
) (*LLMResponse, error) {
return p.ChatStreamEvents(
ctx,
messages,
tools,
model,
options,
func(chunk StreamChunk) {
if onChunk != nil && strings.TrimSpace(chunk.Content) != "" {
onChunk(chunk.Content)
}
},
)
}
func (p *Provider) ChatStreamEvents(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
model string,
options map[string]any,
onChunk func(StreamChunk),
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
requestBody := p.buildRequestBody(messages, tools, model, options)
requestBody["stream"] = true
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")
req.Header.Set("Accept", "text/event-stream")
if p.userAgent != "" {
req.Header.Set("User-Agent", p.userAgent)
}
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
p.applyCustomHeaders(req)
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
// Use a client without Timeout for streaming — the http.Client.Timeout covers
// the entire request lifecycle including body reads, which would kill long streams.
// Context cancellation still provides the safety net.
streamClient := &http.Client{Transport: p.httpClient.Transport}
resp, err := streamClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, common.HandleErrorResponse(resp, p.apiBase)
}
return parseStreamResponse(ctx, withStreamingReadIdleTimeout(resp.Body, defaultStreamingReadIdleTimeout), onChunk)
}
func withStreamingReadIdleTimeout(body io.ReadCloser, timeout time.Duration) io.ReadCloser {
if body == nil || timeout <= 0 {
return body
}
return &streamingReadIdleTimeoutBody{
body: body,
timeout: timeout,
}
}
type streamingReadIdleTimeoutBody struct {
body io.ReadCloser
timeout time.Duration
}
func (b *streamingReadIdleTimeoutBody) Read(p []byte) (int, error) {
timedOut := make(chan struct{})
timer := time.AfterFunc(b.timeout, func() {
close(timedOut)
_ = b.body.Close()
})
n, err := b.body.Read(p)
if !timer.Stop() {
<-timedOut
return n, fmt.Errorf("stream idle timeout after %s", b.timeout)
}
return n, err
}
func (b *streamingReadIdleTimeoutBody) Close() error {
return b.body.Close()
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}
// parseStreamResponse parses an OpenAI-compatible SSE stream.
func parseStreamResponse(
ctx context.Context,
reader io.Reader,
onChunk func(StreamChunk),
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
) (*LLMResponse, error) {
var textContent strings.Builder
var reasoningContent strings.Builder
var reasoning strings.Builder
var reasoningDetails []ReasoningDetail
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
var finishReason string
var usage *UsageInfo
// Tool call assembly: OpenAI streams tool calls as incremental deltas
type toolAccum struct {
id string
name string
argsJSON strings.Builder
}
activeTools := map[int]*toolAccum{}
processEvent := func(data string) error {
if strings.TrimSpace(data) == "" {
return nil
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}
if strings.TrimSpace(data) == "[DONE]" {
return io.EOF
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
ToolCalls []struct {
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
Index int `json:"index"`
ID string `json:"id"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *UsageInfo `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return fmt.Errorf("failed to decode stream event: %w", err)
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}
if chunk.Usage != nil {
usage = chunk.Usage
}
if len(chunk.Choices) == 0 {
return nil
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}
choice := chunk.Choices[0]
if choice.Delta.ReasoningContent != "" {
reasoningContent.WriteString(choice.Delta.ReasoningContent)
if onChunk != nil {
onChunk(StreamChunk{ReasoningContent: reasoningContent.String()})
}
}
if choice.Delta.Reasoning != "" {
reasoning.WriteString(choice.Delta.Reasoning)
if onChunk != nil {
onChunk(StreamChunk{ReasoningContent: reasoning.String()})
}
}
if len(choice.Delta.ReasoningDetails) > 0 {
reasoningDetails = append(reasoningDetails, choice.Delta.ReasoningDetails...)
}
// Accumulate text content after reasoning so UIs can show thought first
// when a provider sends both fields in the same event.
if choice.Delta.Content != "" {
textContent.WriteString(choice.Delta.Content)
if onChunk != nil {
onChunk(StreamChunk{Content: textContent.String()})
}
}
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
// Accumulate tool call deltas
for _, tc := range choice.Delta.ToolCalls {
acc, ok := activeTools[tc.Index]
if !ok {
acc = &toolAccum{}
activeTools[tc.Index] = acc
}
if tc.ID != "" {
acc.id = tc.ID
}
if tc.Function != nil {
if tc.Function.Name != "" {
acc.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
acc.argsJSON.WriteString(tc.Function.Arguments)
}
}
}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
return nil
}
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024) // 1MB initial, 10MB max
var eventData strings.Builder
for scanner.Scan() {
// Check for context cancellation between chunks
if err := ctx.Err(); err != nil {
return nil, err
}
line := scanner.Text()
if line == "" {
err := processEvent(eventData.String())
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
eventData.Reset()
continue
}
if strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimPrefix(line, "data:")
data = strings.TrimPrefix(data, " ")
if eventData.Len() > 0 {
eventData.WriteByte('\n')
}
eventData.WriteString(data)
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("streaming read error: %w", err)
}
if eventData.Len() > 0 {
err := processEvent(eventData.String())
if err != nil && err != io.EOF {
return nil, err
}
}
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
// Assemble tool calls from accumulated deltas
var toolCalls []ToolCall
for i := 0; i < len(activeTools); i++ {
acc, ok := activeTools[i]
if !ok {
continue
}
args := make(map[string]any)
raw := acc.argsJSON.String()
if raw != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
logger.WarnCF("openai_compat", "stream: failed to decode tool call arguments", map[string]any{
"tool": acc.name,
"error": err.Error(),
})
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
args["raw"] = raw
}
}
toolCalls = append(toolCalls, ToolCall{
ID: acc.id,
Name: acc.name,
Arguments: args,
})
}
if finishReason == "" {
finishReason = "stop"
}
return &LLMResponse{
Content: textContent.String(),
ReasoningContent: reasoningContent.String(),
Reasoning: reasoning.String(),
ReasoningDetails: reasoningDetails,
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: usage,
feat(telegram): stream LLM responses via sendMessageDraft (#1101) * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * feat(telegram): stream LLM responses in real-time via sendMessageDraft Implements real-time token streaming to Telegram using the sendMessageDraft API (telego v1.6.0). Instead of showing only a "Thinking..." placeholder until the full response arrives, users now see partial LLM output appear in the chat as it's generated. The streaming pipeline threads through all layers: - StreamingProvider interface (providers/types.go): opt-in ChatStream() method that receives an onChunk callback with accumulated text - OpenAI-compatible SSE streaming (openai_compat/provider.go): parses SSE events with stream:true, handles text deltas and tool call assembly - Anthropic native streaming (anthropic/provider.go): uses SDK's NewStreaming() for direct Anthropic API connections - HTTPProvider delegation (http_provider.go): delegates ChatStream to the underlying openai_compat provider - StreamingCapable + Streamer interfaces (channels/interfaces.go): opt-in channel capability like TypingCapable/PlaceholderCapable - Telegram streamer (telegram/telegram.go): BeginStream returns a telegramStreamer that throttles sendMessageDraft calls (3s/200 chars) with graceful degradation on API errors - StreamDelegate bridge (bus/bus.go): decouples agent loop from channel manager without tight imports - Manager integration (manager.go): implements StreamDelegate, tracks streamActive state, coordinates with placeholder editing - Agent loop (loop.go): uses ChatStream when both provider and channel support streaming, cancels stream on tool calls, skips PublishOutbound when Finalize already delivered the message Graceful degradation: - Bots without forum/topics mode: first sendMessageDraft error sets failed=true, subsequent Updates become no-ops, Finalize still delivers via SendMessage. User sees normal non-streaming behavior. - Non-streaming providers: type assertion fails, falls back to Chat() - Config opt-out: streaming.enabled (default true) in telegram config Closes #1098 * fix(telegram): delete placeholder message when streaming delivers response When streaming was active, the "Thinking..." placeholder message stayed in the chat because preSend only deleted the tracking entry without removing the actual Telegram message. Now preSend deletes the placeholder via the new MessageDeleter interface when streamActive is set. * refactor(streaming): remove dead code and simplify streaming wiring - Delete unused Anthropic ChatStream/parseStream (-131 lines) — factory creates HTTPProvider for all OpenAI-compat providers including OpenRouter - Simplify runLLMIteration from 4 to 3 return values (remove unused streamed bool) - Replace managerStreamer struct with finalizeHookStreamer using embedding (Update/Cancel promoted, only Finalize overridden) * fix(streaming): skip streamer acquisition when SendResponse is false Heartbeat messages set SendResponse=false but the streaming path was unconditionally acquiring a streamer, causing HEARTBEAT_OK to leak to Telegram via streamer.Finalize(). * fix(streaming): guard streamer for non-sendable messages, add streaming config Skip streamer acquisition for heartbeat (NoHistory=true), preventing HEARTBEAT_OK from leaking to Telegram via streamer.Finalize(). Add streaming.enabled to Telegram defaults and example config. * fix(picoclaw): add missing closing brace for StreamingProvider interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve golangci-lint formatting issues Fix gci import ordering in telegram and anthropic provider, and break long function signature in openai_compat provider to satisfy golines. * fix: address code review feedback on streaming PR - Deduplicate Streamer interface: alias channels.Streamer to bus.Streamer to prevent type drift across packages - Increase SSE scanner buffer to 10MB max to handle large single-line responses that exceed bufio.Scanner's 64KB default - Switch draftID generation from math/rand to crypto/rand for collision-resistant random IDs - Add context cancellation check in SSE parsing loop so cancelled streams stop processing immediately - Log Finalize failures with chat_id and content length for debugging silent message delivery failures * feat: make streaming throttle interval and min growth configurable Move hardcoded streamThrottleInterval (3s) and streamMinGrowth (200) into StreamingConfig so they can be tuned per deployment via config or environment variables. * fix(telegram): use parseTelegramChatID in DeleteMessage and BeginStream These two functions called undefined parseChatID. Use parseTelegramChatID with _ for the unused threadID instead of adding a wrapper function. Fixes all three CI checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(streaming): set streamActive only after successful Finalize Move onFinalize hook to run after Streamer.Finalize succeeds, so that if Finalize fails the streamActive flag stays false and the regular placeholder fallback path remains available. Addresses review feedback from @alexhoshina. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:04:14 +00:00
}, nil
}
func normalizeModel(model, apiBase string) string {
before, after, ok := strings.Cut(model, "/")
if !ok {
return model
}
if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
return model
}
prefix := strings.ToLower(before)
if _, ok := stripModelPrefixProviders[prefix]; ok {
return after
}
return model
}
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
result := make([]any, 0, len(tools)+1)
for _, t := range tools {
if nativeSearch && strings.EqualFold(t.Function.Name, "web_search") {
continue
}
result = append(result, t)
}
if nativeSearch {
result = append(result, map[string]any{"type": "web_search_preview"})
}
return result
}
func (p *Provider) SupportsNativeSearch() bool {
return isNativeSearchHost(p.apiBase)
}
2026-04-19 04:20:00 +00:00
// isNativeOpenAIOrAzureEndpoint reports whether the given API base points to
// OpenAI's own API or an Azure OpenAI deployment.
func isNativeOpenAIOrAzureEndpoint(apiBase string) bool {
u, err := url.Parse(apiBase)
if err != nil {
return false
}
host := u.Hostname()
return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com")
}
2026-04-19 04:20:00 +00:00
func isNativeSearchHost(apiBase string) bool {
return isNativeOpenAIOrAzureEndpoint(apiBase)
}
// supportsPromptCacheKey reports whether the given API base is known to
// support the prompt_cache_key request field. Currently only OpenAI's own
// API and Azure OpenAI support this. All other OpenAI-compatible providers
// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors.
func supportsPromptCacheKey(apiBase string) bool {
2026-04-19 04:20:00 +00:00
return isNativeOpenAIOrAzureEndpoint(apiBase)
}