fix: merge tool_calls from multiple choices in LiteLLM response

GitHub Copilot's API returns tool_calls split across multiple choices:
- choices[0]: content only (tool_calls=null)
- choices[1]: tool_calls only (content=null)

The existing _parse_response only inspected choices[0], so tool_calls
were silently lost, causing the agent to never execute tools when using
github_copilot/ models.

This fix scans all choices and merges tool_calls + content, so
providers that return multi-choice responses work correctly.
Single-choice providers (OpenAI, Anthropic, etc.) are unaffected since
the loop over one choice is equivalent to the original code.
This commit is contained in:
Peixian Gong
2026-03-05 15:15:37 +08:00
parent fb74281434
commit 5cc3c03245

View File

@@ -8,6 +8,7 @@ from typing import Any
import json_repair import json_repair
import litellm import litellm
from litellm import acompletion from litellm import acompletion
from loguru import logger
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.registry import find_by_model, find_gateway from nanobot.providers.registry import find_by_model, find_gateway
@@ -255,20 +256,37 @@ class LiteLLMProvider(LLMProvider):
"""Parse LiteLLM response into our standard format.""" """Parse LiteLLM response into our standard format."""
choice = response.choices[0] choice = response.choices[0]
message = choice.message message = choice.message
content = message.content
finish_reason = choice.finish_reason
# Some providers (e.g. GitHub Copilot) split content and tool_calls
# across multiple choices. Merge them so tool_calls are not lost.
raw_tool_calls = []
for ch in response.choices:
msg = ch.message
if hasattr(msg, "tool_calls") and msg.tool_calls:
raw_tool_calls.extend(msg.tool_calls)
if ch.finish_reason in ("tool_calls", "stop"):
finish_reason = ch.finish_reason
if not content and msg.content:
content = msg.content
if len(response.choices) > 1:
logger.debug("LiteLLM response has {} choices, merged {} tool_calls",
len(response.choices), len(raw_tool_calls))
tool_calls = [] tool_calls = []
if hasattr(message, "tool_calls") and message.tool_calls: for tc in raw_tool_calls:
for tc in message.tool_calls: # Parse arguments from JSON string if needed
# Parse arguments from JSON string if needed args = tc.function.arguments
args = tc.function.arguments if isinstance(args, str):
if isinstance(args, str): args = json_repair.loads(args)
args = json_repair.loads(args)
tool_calls.append(ToolCallRequest( tool_calls.append(ToolCallRequest(
id=_short_tool_id(), id=_short_tool_id(),
name=tc.function.name, name=tc.function.name,
arguments=args, arguments=args,
)) ))
usage = {} usage = {}
if hasattr(response, "usage") and response.usage: if hasattr(response, "usage") and response.usage:
@@ -282,9 +300,9 @@ class LiteLLMProvider(LLMProvider):
thinking_blocks = getattr(message, "thinking_blocks", None) or None thinking_blocks = getattr(message, "thinking_blocks", None) or None
return LLMResponse( return LLMResponse(
content=message.content, content=content,
tool_calls=tool_calls, tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop", finish_reason=finish_reason or "stop",
usage=usage, usage=usage,
reasoning_content=reasoning_content, reasoning_content=reasoning_content,
thinking_blocks=thinking_blocks, thinking_blocks=thinking_blocks,