BerriAI/litellm · error · OCIError
Message `tool_calls` must be a list
Error message
Message `tool_calls` must be a list
What it means
In the top-level message loop, an assistant message with a non-None `tool_calls` must have tool_calls as a list. Anything else (a single dict, a JSON string, an int) raises OCIError 400 before per-item validation. This is the coarse type gate that precedes adapt_messages_to_generic_oci_standard_tool_call.
Solutions
- Wrap tool calls in a list: "tool_calls": [call] even for a single call
- json.loads the whole message list, not individual fields piecemeal
- Normalize at the boundary: msg["tool_calls"] = list(msg["tool_calls"].values()) style fixes in your adapter
- Type-check messages before dispatch (type guard below)
Example fix
# before
{"role":"assistant","content":None,"tool_calls":{"id":"1","type":"function","function":{"name":"f","arguments":"{}"}}}
# after
{"role":"assistant","content":None,"tool_calls":[{"id":"1","type":"function","function":{"name":"f","arguments":"{}"}}]} Defensive patterns
Strategy: validation
Validate before calling
import json
def normalize_assistant_messages(messages: list) -> list:
out = []
for m in messages:
tc = m.get("tool_calls")
if m.get("role") == "assistant" and tc is not None and not isinstance(tc, list):
tc = [tc] if isinstance(tc, dict) else json.loads(tc)
m = {**m, "tool_calls": tc}
out.append(m)
return out Type guard
def tool_calls_is_list(m) -> bool:
tc = m.get("tool_calls")
return tc is None or isinstance(tc, list) Prevention
- Always wrap tool calls in a list, including the single-call case
- json.loads the serialized message array once, at the top level
When it happens
Trigger: messages=[{"role":"assistant","tool_calls":{"id":..., "function":...}}] (single object not wrapped in a list) or tool_calls="[...]" (stringified) sent to an oci/generic.* model. Only fires when role == "assistant" and tool_calls is not None.
Common situations: Wrapping a single tool call without brackets; history stored as JSON and json.loads applied at the wrong level; frameworks returning a bare object for single calls; copy-paste from OpenAI docs examples that show the object form inline.
Related errors
- Each tool call must be a dictionary
- OCI only supports function tool calls
- Tool call `function.arguments` must be a JSON string
- Tool call `function` must be a dictionary
- Tool call `function.name` must be a string
AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18).
Data as JSON: /api/errors/95b821b0627deadd.
Report an issue: GitHub.
Appendix: source
Thrown at litellm/llms/oci/chat/generic.py:182
toolCalls=None,
toolCallId=tool_call_id,
)
def adapt_messages_to_generic_oci_standard(
messages: list[AllMessageValues],
) -> list[OCIMessage]:
"""Convert an OpenAI-format message array to OCI GENERIC format."""
new_messages: Final = []
for message in messages:
role = message["role"]
content = message.get("content")
tool_calls = message.get("tool_calls")
tool_call_id = message.get("tool_call_id")
if role == "assistant" and tool_calls is not None:
if not isinstance(tool_calls, list):
raise OCIError(status_code=400, message="Message `tool_calls` must be a list")
new_messages.append(adapt_messages_to_generic_oci_standard_tool_call(role, tool_calls))
elif role in ["system", "user", "assistant"] and content is not None:
if not isinstance(content, (str, list)):
raise OCIError(
status_code=400,
message="Message `content` must be a string or list of content parts",
)
new_messages.append(adapt_messages_to_generic_oci_standard_content_message(role, content))
elif role == "tool":
if not isinstance(tool_call_id, str):
raise OCIError(
status_code=400,
message="Tool result message must have a string `tool_call_id`",
)
if not isinstance(content, str):
raise OCIError(View on GitHub (pinned to 77b7c6c40c)