BerriAI/litellm · error · OCIError
Each tool call must be a dictionary
Error message
Each tool call must be a dictionary
What it means
When converting an assistant message that carries `tool_calls` to OCI GENERIC format, each element of the tool_calls list must be a dict (an OpenAI tool-call object). A non-dict element (string, tuple, serialized JSON) raises OCIError 400 during message adaptation. Fail-fast client-side validation of replayed assistant tool-call messages.
Solutions
- Deserialize before sending: tool_calls=[json.loads(tc) if isinstance(tc, str) else tc for tc in msg["tool_calls"]]
- Keep tool_calls as plain dicts end-to-end in your history store (avoid str()/repr() round-trips)
- Echo back the exact list you received from a previous litellm/OpenAI response without transformation
- Add a shape assertion on persisted history at load time
Example fix
# before (history reloaded from DB with stringified calls)
msg = {"role": "assistant", "content": None, "tool_calls": [json.dumps(tc)]}
# after
msg = {"role": "assistant", "content": None, "tool_calls": [tc]} # keep dicts Defensive patterns
Strategy: validation
Validate before calling
import json
def normalize_tool_calls(tool_calls: list) -> list:
return [json.loads(tc) if isinstance(tc, str) else tc for tc in tool_calls] Type guard
def has_valid_tool_call_items(tool_calls) -> bool:
return isinstance(tool_calls, list) and all(isinstance(tc, dict) for tc in tool_calls) Prevention
- Store tool_calls as structured dicts in history, never stringified
- json.loads at the exact level you json.dumps'd — one level only
When it happens
Trigger: Replaying a conversation history where an assistant message has tool_calls=["{...}"] (JSON-stringified call) or a tuple — common when history round-tripped through a queue/DB and items were serialized. Only assistant messages with tool_calls != None hit this path, for oci/generic.* models.
Common situations: Storing tool_calls as JSON strings in a DB and loading them back without json.loads; LangChain intermediate representations; manually echoing the model's tool_calls but double-encoding; message dicts built from LLM-generated JSON.
Related errors
- Message `tool_calls` must be a list
- 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/475123c3a35a4e69.
Report an issue: GitHub.
Appendix: source
Thrown at litellm/llms/oci/chat/generic.py:119
status_code=400,
message="Prop `image_url` must be a string or an object with a `url` property",
)
new_content.append(OCIImageContentPart(imageUrl=OCIImageUrl(url=image_url)))
return OCIMessage(
role=open_ai_to_generic_oci_role_map[role],
content=new_content,
toolCalls=None,
toolCallId=None,
)
def adapt_messages_to_generic_oci_standard_tool_call(role: str, tool_calls: list) -> OCIMessage:
"""Convert an assistant tool-call message to OCI format."""
tool_calls_formatted: Final = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
raise OCIError(status_code=400, message="Each tool call must be a dictionary")
if tool_call.get("type") != "function":
raise OCIError(status_code=400, message="OCI only supports function tool calls")
tool_call_id = tool_call.get("id")
if not isinstance(tool_call_id, str):
raise OCIError(status_code=400, message="Tool call `id` must be a string")
tool_function = tool_call.get("function")
if not isinstance(tool_function, dict):
raise OCIError(status_code=400, message="Tool call `function` must be a dictionary")
function_name = tool_function.get("name")
if not isinstance(function_name, str):
raise OCIError(status_code=400, message="Tool call `function.name` must be a string")
arguments = tool_call["function"].get("arguments", "{}")
if not isinstance(arguments, str):
raise OCIError(View on GitHub (pinned to 77b7c6c40c)