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

  1. Wrap tool calls in a list: "tool_calls": [call] even for a single call
  2. json.loads the whole message list, not individual fields piecemeal
  3. Normalize at the boundary: msg["tool_calls"] = list(msg["tool_calls"].values()) style fixes in your adapter
  4. 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

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


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)