Skip to content

fix(openai_compat): fix tool call serialization for strict OpenAI-compatible providers#1460

Open
securityguy wants to merge 50 commits intosipeed:mainfrom
securityguy:main
Open

fix(openai_compat): fix tool call serialization for strict OpenAI-compatible providers#1460
securityguy wants to merge 50 commits intosipeed:mainfrom
securityguy:main

Conversation

@securityguy
Copy link
Copy Markdown
Contributor

Summary

  • Fix empty content with tool_calls: When the assistant responds with only tool calls (no text), the serialized message previously included "content": "". The OpenAI spec allows content to be absent when tool_calls is present, and some strict providers (e.g. certain backends routed via OpenRouter) reject the empty string. The field is now omitted in that case.

  • Add strict_compat option: Some providers reject non-standard fields that picoclaw preserves for extended model features (reasoning_content, extra_content, thought_signature). A new per-model strict_compat: true config option strips these fields before serialization. Defaults to false so existing behaviour is unchanged.

    Also refactors the HTTPProvider constructors from a growing list of named variants into a single NewHTTPProviderWithOptions(apiKey, apiBase, proxy string, opts ...openai_compat.Option), making future options trivial to add.

Usage

{
  "model_name": "openrouter-strict",
  "model": "openrouter/anthropic/claude-sonnet-4-5",
  "api_key": "sk-or-v1-...",
  "strict_compat": true
}

Test plan

  • Existing openai_compat tests pass
  • TestSerializeMessages_OmitsContentWhenEmptyAndToolCallsPresent — content absent when tool_calls present and content is empty
  • TestSerializeMessages_IncludesContentWhenNonEmptyWithToolCalls — content preserved when non-empty alongside tool_calls
  • TestSerializeMessages_StrictCompat_StripsReasoningContent — reasoning_content stripped when strict_compat=true
  • TestSerializeMessages_StrictCompat_StripsExtraContent — extra_content stripped when strict_compat=true
  • TestSerializeMessages_StrictCompat_StripsThoughtSignature — thought_signature stripped when strict_compat=true
  • TestSerializeMessages_NoStrictCompat_PreservesFields — all fields preserved when strict_compat=false

🤖 Generated with Claude Code

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 13, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

securityguy and others added 9 commits March 15, 2026 20:53
Some providers (via OpenRouter) reject assistant messages with
"content": "" alongside tool_calls. The OpenAI spec permits content to
be absent when tool_calls is set. Switch openaiMessage.Content from
string to *string with omitempty and introduce msgContent() to return
nil when content is empty and tool calls are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ields

Some OpenAI-compatible providers (e.g. OpenRouter routing to strict
backends) reject non-standard fields in the request body such as
reasoning_content in messages and extra_content / thought_signature
in tool calls. Add a per-model strict_compat: true config option that
strips these fields before serialization.

Implementation:
- Add StrictCompat bool to config.ModelConfig
- Add WithStrictCompat option to openai_compat.Provider
- Refactor HTTPProvider constructors into a single NewHTTPProviderWithOptions
  using variadic openai_compat.Option, eliminating the growing list of
  named constructors
- Thread StrictCompat through CreateProviderFromConfig via composed options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the claude CLI exits with a non-zero status, the previous error
handler only checked stderr. However, the CLI writes its output
(including error details) to stdout, especially when invoked with
--output-format json. This left the caller with only "exit status 1"
and no actionable information.

Now includes both stderr and stdout in the error message so the actual
failure reason is visible in logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add claude-cli and codex-cli to the supported vendors table and
include vendor-specific configuration examples explaining:
- No API key is required (uses existing CLI subscription)
- The claude-code sentinel model ID skips --model flag so the CLI
  uses its own configured default model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add channels.telegram_bots config allowing multiple Telegram bot tokens
to be configured, each mapped to a separate channel (e.g. telegram-amber,
telegram-karen). Each channel can be independently bound to an agent via
the bindings config, enabling distinct AI personas behind separate bots.

Backward compatibility is preserved: the existing channels.telegram
single-entry config continues to work unchanged. On load it is normalized
into telegram_bots as an entry with id "default", which produces the
channel name "telegram" so all existing bindings remain valid.

Key changes:
- config: add TelegramBotConfig struct with ChannelName/AsTelegramConfig
  helpers; add TelegramBots field to ChannelsConfig; normalize legacy
  single entry into list on load
- telegram: add NewTelegramChannelFromConfig constructor accepting
  TelegramConfig + explicit channel name (avoids import cycle)
- channels: add TelegramBotFactory registry; add injectChannelDependencies
  helper to eliminate injection code duplication; add duplicate channel
  name guard in initTelegramBot; update initChannels to iterate over
  TelegramBots; add prefix-based rate limit fallback for named bots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le.json

Add two disabled example bots (alice, bob) under channels.telegram_bots
and corresponding top-level bindings to illustrate how multiple Telegram
bots map to separate named channels and agents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
securityguy and others added 13 commits March 15, 2026 20:55
Adds GeminiCliProvider that wraps the Gemini CLI as a subprocess,
following the same pattern as the existing claude-cli and codex-cli
providers.

The provider invokes:
  gemini --yolo --output-format json --prompt ""

with the prompt sent via stdin. The --prompt "" flag enables
non-interactive (headless) mode, reading the full prompt from stdin.

Key details:
- Model sentinel: "gemini-cli" skips --model flag (uses CLI default)
- Explicit model: "gemini-cli/gemini-2.5-pro" passes --model gemini-2.5-pro
- System messages prepended to stdin (no --system-prompt flag in gemini)
- Parses JSON response format: {"response": "...", "stats": {"models": {...}}}
- Token usage summed across all models in stats.models (gemini uses
  multiple internal models per request)
- Tool calls extracted from response text using shared extractToolCallsFromText
- New protocol: "gemini-cli" / alias "geminicli"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add PR sipeed#1633 (gemini-cli provider) to contributions table
- Add configuration guide section covering:
  - claude-cli, codex-cli, and gemini-cli providers with model_list examples
  - Multiple Telegram bots with bindings and per-agent config
  - Agent workspace and personality file notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds GeminiCliProvider (PR sipeed#1633 against sipeed/picoclaw).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace Amber/Karen with Alice/Bob in all README examples for consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously all agents shared a single LLMProvider instance created from
agents.defaults.model_name. Per-agent model config (agents.list[].model)
only changed the model string passed to Chat() — it never changed which
provider binary was invoked. This caused cross-provider fallback chains
(e.g. gemini-cli falling back to claude-cli) to fail, and made it
impossible to assign different CLI providers to different agents.

Introduces ProviderDispatcher which lazily creates and caches provider
instances keyed by "protocol/modelID". The fallback chain's run closure
now resolves the correct provider via the dispatcher before falling back
to agent.Provider for backward compatibility.

References sipeed#1634

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brings in ProviderDispatcher fix (PR sipeed#1637 against sipeed/picoclaw).
References sipeed#1634.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ndling

Tool call detection previously relied on a literal strings.Index for
'{"tool_calls"' which failed whenever the LLM returned pretty-printed
JSON (newline after '{') or wrapped the output in markdown code fences.
Arguments typed as a JSON object instead of an encoded string also
caused a silent parse failure and leaked the raw JSON block to the user.

Changes:
- Strip markdown code fences (` + "```json" + ` / ` + "```" + `) before parsing
- Locate JSON candidate via first '{' / last '}' instead of literal match
- Unmarshal directly and check for top-level "tool_calls" key
- Accept arguments as either a JSON-encoded string or a plain JSON object
- Remove dead findMatchingBrace function and its tests
- Publish response.Content to the user immediately when a response
  contains both text and tool calls (previously the text was silently
  discarded into session history)
- Fix pre-existing test bug: TestCreateProvider_GeminiCliDefaultWorkspace
  now clears Agents.Defaults.Workspace before testing the '.' fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
requiresRuntimeProbe and probeLocalModelAvailability handled claude-cli
and codex-cli but not gemini-cli, causing the launcher to report
"default model has no credentials configured" and skip auto-start when
gemini-cli was set as the default model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TryAutoStartGateway only checked gateway.cmd, which tracks processes the
launcher itself spawned. A gateway managed externally (e.g. via systemd)
was invisible to this check, causing two problems:
  1. The launcher started a duplicate gateway instance on every launch.
  2. The WebUI showed "Gateway Not Running" even when it was healthy.

Fix: probe the gateway health endpoint in two places:
  - TryAutoStartGateway: skip auto-start if the health endpoint responds.
  - gatewayStatusData: report "running" when the launcher has no owned
    process but the health endpoint is responding. Launcher-owned
    transition states (restarting/error) take precedence over the probe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
securityguy and others added 4 commits March 19, 2026 18:25
… allowlist on self-spawn

Previously SubagentManager was initialised with the global provider and
the calling agent's model name string (e.g. "openrouter-gpt-5.4").
When the global provider happened to be claude-cli this caused it to be
invoked with --model openrouter-gpt-5.4, which claude does not recognise,
blowing up every spawn attempt.

Two problems fixed together:

1. Provider dispatch: SubagentManager now holds the ProviderDispatcher and
   the calling agent's model candidates. When a subagent is spawned it
   resolves the correct provider through the same per-candidate dispatch
   used by the main agent loop. When agent_id names a different agent,
   that agent's candidates are resolved via a registry callback so the
   subagent runs with the target agent's configured model (e.g. spawning
   "karen" uses karen's claude-cli, not amber's openrouter).

2. Self-spawn allowlist: the allowlist check previously only ran when
   agent_id was explicitly set. Empty agent_id (self-spawn) now resolves
   to the caller's own ID before the check, so allow_agents: ["karen"]
   on amber correctly rejects an unqualified spawn attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
securityguy and others added 20 commits March 19, 2026 23:10
When a named subagent (e.g. agent_id="karen") completes a task, its
response is now prefixed with the agent's name in bold so the user
can tell which agent produced the result. Self-spawns (no agent_id)
are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The list command previously showed only name, ID, schedule, status, and
next run time. Channel, recipient, deliver mode, and message/command were
hidden, making it impossible to tell from the CLI what a job would do or
which agent would handle it.

All payload fields are now displayed. Messages longer than 80 characters
are truncated with an ellipsis. Jobs with a command show the command
instead of a message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ectly

Cron jobs fired via ProcessDirectWithChannel had no Peer set on the
InboundMessage, so the route resolver never matched peer-based bindings
and always fell through to the default agent.

Setting Peer{Kind: "channel", ID: chatID} when a real chatID is present
means a cron job targeted at a specific Slack channel ID will now be
routed to whichever agent has a matching peer binding for that channel,
consistent with how live inbound messages are routed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a cron job with deliver=false runs, the agent response was silently
discarded with a misleading comment "Will be sent by AgentLoop". The Run
loop is never involved in cron execution — ProcessDirectWithChannel bypasses
it entirely. As a result, Karen's response to scheduled tasks was computed
but never delivered to Slack (or any other channel).

Fix: explicitly publish the response via msgBus.PublishOutbound after
ProcessDirectWithChannel returns, consistent with how command and
deliver=true jobs already handle their output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… changes

Two related bugs caused cron jobs added via the CLI to be silently lost
while picoclaw was running:

1. checkJobs() called saveStoreUnsafe() unconditionally every second,
   overwriting the file with the running service's in-memory state. Any
   job written by the CLI was clobbered within at most one tick.

2. The running service never reloaded the store from disk, so CLI-added
   jobs were invisible in memory and would never execute even if the file
   was not overwritten.

Fix: track the store file's mtime in fileModTime (updated after every
load and save). At the start of each checkJobs() tick, stat the file; if
its mtime is newer than fileModTime, reload from disk. Only call
saveStoreUnsafe() when there are actually due jobs — no state changes
means no write, which stops the clobbering entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude-cli, codex-cli, and gemini-cli ignored the request_timeout config
field entirely — the factory created them without any timeout and the
subprocesses ran indefinitely. This is particularly problematic for
agentic tasks (e.g. cron jobs) where a runaway Claude Code session could
block the agent loop forever.

Each CLI provider gains a timeout field and a WithTimeout constructor.
When request_timeout > 0 in the model config, Chat() wraps the incoming
context with context.WithTimeout before passing it to exec.CommandContext,
so the subprocess is killed and an error is returned if the deadline is
exceeded. A value of 0 (the default) leaves behaviour unchanged — no
timeout is applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…der timeout

When a CLI provider subprocess was killed due to request_timeout, the error
reported was the raw subprocess signal ("signal: killed"), giving the user
no indication that a timeout was the cause. Additionally, the fallback chain
would not trigger because ClassifyError used == to match context.DeadlineExceeded,
which does not match wrapped errors.

- Each CLI provider now checks ctx.Err() == context.DeadlineExceeded after
  cmd.Run() fails and returns a descriptive error (e.g. "claude cli timed out
  after 30s") that wraps context.DeadlineExceeded
- ClassifyError updated to use errors.Is instead of == when checking for
  context.DeadlineExceeded, so wrapped timeout errors correctly classify as
  FailoverTimeout and trigger fallback to the next candidate in the chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…I providers with clear timeout errors and fallback
Updated cautionary note regarding repository maintenance status.
Revise caution statement in README
Copilot AI review requested due to automatic review settings March 28, 2026 18:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves compatibility with strict OpenAI-compatible chat completion endpoints and expands provider/channel infrastructure to better support multi-provider fallback, CLI-based providers, multi-bot Telegram setups, and more robust cron/gateway behaviors.

Changes:

  • Fix OpenAI-compatible message serialization (omit empty content when tool_calls exist; add strict_compat to strip non-standard fields).
  • Add per-candidate provider dispatch (dispatcher + cached per-model providers) and extend CLI providers (Gemini CLI, request timeouts, improved tool-call extraction).
  • Expand operational features: multi-bot Telegram support, cron store reload semantics + outbound publishing, and launcher gateway health probing.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
web/backend/api/model_status.go Treats gemini-cli as a CLI protocol needing runtime probe/availability handling.
web/backend/api/gateway.go Avoids auto-starting gateway when an externally managed gateway is already healthy; improves status reporting.
pkg/tools/toolloop.go Adds optional dispatcher/candidate/fallback execution path for tool loops.
pkg/tools/subagent_tool_test.go Updates tests to new SubagentManagerConfig constructor API.
pkg/tools/subagent.go Refactors subagent manager construction/config; adds per-agent dispatch and response attribution.
pkg/tools/spawn_test.go Updates tests to new SubagentManagerConfig constructor API.
pkg/tools/spawn.go Applies allowlist checks to self-spawn cases (using caller agent ID).
pkg/tools/cron.go Publishes cron job responses directly to outbound bus (deliver, command output, and agent responses).
pkg/providers/tool_call_extract_test.go Adds comprehensive tests for extracting/stripping tool_calls JSON (pretty JSON, code fences, mixed text).
pkg/providers/tool_call_extract.go Reworks CLI tool-call extraction/stripping (code-fence tolerant, args as string/object).
pkg/providers/openai_compat/provider_test.go Adds tests for empty-content-with-toolcalls and strict-compat field stripping.
pkg/providers/openai_compat/provider.go Implements strictCompat option and new serialization that omits empty content when tool_calls exist.
pkg/providers/http_provider.go Simplifies HTTPProvider constructors into NewHTTPProviderWithOptions.
pkg/providers/gemini_cli_provider_test.go Adds tests for Gemini CLI provider prompt building, response parsing, and factory integration.
pkg/providers/gemini_cli_provider.go Introduces Gemini CLI provider implementation (subprocess, JSON output parsing, usage aggregation).
pkg/providers/factory_provider.go Wires strict_compat + request_timeout options into HTTP providers; adds gemini-cli; adds CLI timeouts for claude/codex.
pkg/providers/error_classifier.go Uses errors.Is for deadline exceeded classification (better wrapped-error handling).
pkg/providers/dispatch_test.go Adds dispatcher cache/flush/thread-safety tests.
pkg/providers/dispatch.go Adds ProviderDispatcher to create/cache per protocol/model providers.
pkg/providers/codex_cli_provider.go Adds request timeout support and clearer timeout errors for codex CLI.
pkg/providers/claude_cli_provider_test.go Updates tests to ensure system prompt is passed via stdin, not argv; adds stdin prompt tests; removes brace-matcher tests.
pkg/providers/claude_cli_provider.go Moves system prompt into stdin payload; adds request timeout; improves error reporting (stdout/stderr surfaced).
pkg/cron/service.go Reloads cron store when file changes externally; avoids saving when no due jobs; tracks store file mtime.
pkg/config/config.go Adds strict_compat model option; introduces telegram_bots config + normalization from legacy channels.telegram.
pkg/channels/telegram/telegram.go Supports constructing Telegram channels from explicit TelegramConfig + channel name (for named bots).
pkg/channels/telegram/init.go Registers Telegram bot factory for named telegram bots initialization.
pkg/channels/registry.go Adds global registration/accessors for a TelegramBotFactory (avoids import cycles).
pkg/channels/manager.go Initializes multiple named Telegram bots; factors out dependency injection; inherits rate limits for channel variants.
pkg/agent/loop_test.go Updates AgentLoop construction to accept dispatcher parameter.
pkg/agent/loop.go Adds dispatcher usage for fallback candidates; flushes dispatcher on reload; tweaks direct-processing peer metadata; publishes assistant text when tool_calls present.
config/config.example.json Documents bindings + telegram_bots usage in example config.
cmd/picoclaw/internal/gateway/helpers.go Instantiates and passes ProviderDispatcher into AgentLoop.
cmd/picoclaw/internal/cron/helpers.go Improves cron list output formatting and truncates message display.
cmd/picoclaw/internal/agent/helpers.go Instantiates and passes ProviderDispatcher into AgentLoop.
README.md Replaces README with fork status + configuration guide for added features.
.github/dependabot.yml Removes dependabot configuration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/tools/toolloop.go
Comment on lines +96 to +101
first := config.Candidates[0]
if p, perr := config.Dispatcher.Get(first.Provider, first.Model); perr == nil {
response, err = p.Chat(ctx, messages, providerToolDefs, first.Model, llmOpts)
} else {
response, err = config.Provider.Chat(ctx, messages, providerToolDefs, first.Model, llmOpts)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue for the non-fallback dispatch path: when Dispatcher.Get(first.Provider, first.Model) fails, falling back to config.Provider.Chat(first.Model) can send the request to an unintended provider. Better to return the dispatcher error (or explicitly document/guard this behavior) so a missing dispatcher entry doesn't silently change routing.

Copilot uses AI. Check for mistakes.
Comment thread pkg/agent/loop.go
Comment on lines +682 to +687
// Set peer so channel-based bindings (e.g. a specific Slack channel mapped
// to a named agent) are matched by the route resolver, exactly as they are
// for live inbound messages.
if chatID != "" && chatID != "direct" {
msg.Peer = bus.Peer{Kind: "channel", ID: chatID}
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProcessDirectWithChannel unconditionally sets msg.Peer.Kind to "channel" for any non-"direct" chatID. That can break peer-based bindings that expect "direct" or "group" (e.g., bindings matching Telegram DMs/groups), and it may misroute cron/CLI-originated messages compared to real inbound messages. Consider either (a) not setting Peer unless the caller can supply the correct kind, or (b) extending the API / cron payload to carry the peer kind captured from the original inbound message.

Suggested change
// Set peer so channel-based bindings (e.g. a specific Slack channel mapped
// to a named agent) are matched by the route resolver, exactly as they are
// for live inbound messages.
if chatID != "" && chatID != "direct" {
msg.Peer = bus.Peer{Kind: "channel", ID: chatID}
}

Copilot uses AI. Check for mistakes.
Comment thread README.md
</div>
# PicoClaw: Ultra-Efficient AI Assistant in Go

CAUTION: This repos is not maintained and should not be used. It has been left here in case the branches are useful to the picoclaw maintainers.
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: "This repos" should be "This repo".

Suggested change
CAUTION: This repos is not maintained and should not be used. It has been left here in case the branches are useful to the picoclaw maintainers.
CAUTION: This repo is not maintained and should not be used. It has been left here in case the branches are useful to the picoclaw maintainers.

Copilot uses AI. Check for mistakes.
Comment thread README.md
### Telegram bot says "Conflict: terminated by other getUpdates"

This happens when another instance of the bot is running. Make sure only one `picoclaw gateway` is running at a time.
**Note:** The legacy single-bot `channels.telegram` config is still supported and is automatically normalized to a `telegram-default` channel for backward compatibility.
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The note says legacy channels.telegram is normalized to a telegram-default channel, but the code path (TelegramBotConfig.ChannelName) maps id "default" to the channel name "telegram". Update the README to match the actual channel name used for backward compatibility.

Suggested change
**Note:** The legacy single-bot `channels.telegram` config is still supported and is automatically normalized to a `telegram-default` channel for backward compatibility.
**Note:** The legacy single-bot `channels.telegram` config is still supported and is automatically normalized to a `telegram` channel for backward compatibility.

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +11 to +16
| [#1460](https://github.com/sipeed/picoclaw/pull/1460) | fix(openai_compat): fix tool call serialization for strict OpenAI-compatible providers |
| [#1479](https://github.com/sipeed/picoclaw/pull/1479) | fix(claude_cli): surface stdout in error when CLI exits non-zero |
| [#1480](https://github.com/sipeed/picoclaw/pull/1480) | docs: document claude-cli and codex-cli providers in README |
| [#1625](https://github.com/sipeed/picoclaw/pull/1625) | feat(channels): support multiple named Telegram bots |
| [#1633](https://github.com/sipeed/picoclaw/pull/1633) | feat(providers): add gemini-cli provider |
| [#1637](https://github.com/sipeed/picoclaw/pull/1637) | fix(agent): dispatch per-candidate provider in fallback chain |
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR title/description focuses on openai_compat serialization/strict_compat, but this PR also includes broad changes (providers dispatching, telegram multi-bot support, cron behavior, gateway probing, README rewrite, etc.). Either split into focused PRs or expand the PR description to cover the additional behavioral changes and their test plan, to make review and risk assessment tractable.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +97
// Use positions in the original (pre-fence-stripped) text to preserve
// any content outside code fences. If the text was fence-wrapped we strip
// the whole thing; otherwise cut out just the JSON range.
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stripToolCallsFromText validates tool_calls JSON using a fence-stripped copy, but then removes only the {...} range from the original text. When the tool_calls blob is inside markdown code fences, this leaves dangling /json fences in the returned content (so the caller may treat it as non-empty user-visible text). Consider detecting fenced blocks and removing the entire fenced region, or compute removal indexes against the fence-stripped string and map them back correctly.

Suggested change
// Use positions in the original (pre-fence-stripped) text to preserve
// any content outside code fences. If the text was fence-wrapped we strip
// the whole thing; otherwise cut out just the JSON range.
// If the tool_calls JSON is inside a markdown code fence, remove the
// entire fenced block so we don't leave dangling ```/```json markers.
if fenceStart := strings.Index(text, "```"); fenceStart != -1 {
if fenceEndRel := strings.Index(text[fenceStart+3:], "```"); fenceEndRel != -1 {
fenceEnd := fenceStart + 3 + fenceEndRel
// Remove the whole fenced region, preserving any surrounding text.
return strings.TrimSpace(text[:fenceStart] + text[fenceEnd+3:])
}
}
// No fences detected; fall back to removing just the JSON range in the
// original text, preserving any surrounding content.

Copilot uses AI. Check for mistakes.
Comment thread pkg/tools/toolloop.go
Comment on lines +83 to +87
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
if p, perr := config.Dispatcher.Get(providerName, model); perr == nil {
return p.Chat(ctx, messages, providerToolDefs, model, llmOpts)
}
return config.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the per-candidate fallback path, if Dispatcher.Get(providerName, model) fails, this code silently falls back to config.Provider.Chat using the candidate's model. That can route the request to the wrong provider/base URL and can also mask a misconfigured candidate (it should be treated as a failed attempt so the fallback chain can proceed to the next candidate). Consider returning the dispatcher error instead (or wrapping it) so this attempt fails cleanly.

Copilot uses AI. Check for mistakes.
@sipeed-bot
Copy link
Copy Markdown

sipeed-bot Bot commented Apr 17, 2026

@securityguy Hi! This PR has been inactive for over a week. If there's no update in the next 7 days, it will be closed automatically. If you're still working on it, just leave a comment to keep it open!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: config domain: provider go Pull requests that update go code type: bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants