unslothai/unsloth · error · ValueError
"tool_calls" is only valid on role="assistant" messages.
Error message
"tool_calls" is only valid on role="assistant" messages.
What it means
Raised by a ChatMessage model_validator(mode='after') when the message carries a tool_calls array but role is not "assistant". The OpenAI chat schema only permits tool_calls on assistant messages (they record the calls the model made), so any other role is a malformed transcript and rejected as a 400/422.
Source
Thrown at studio/backend/models/inference.py:1256
description = (
"Provider-specific extra fields the translator may read. "
"Gemini reads `extra_content.google.thought_signature` "
"from assistant messages to replay text-part signatures."
),
)
@field_validator("reasoning_content", mode = "before")
@classmethod
def _ignore_non_string_reasoning(cls, value):
# This field used to be ignored as an unknown key. Some compatible
# gateways send structured reasoning, so declaring the string form must
# not turn those previously accepted requests into validation errors.
return value if isinstance(value, str) else None
@model_validator(mode = "after")
def _validate_role_shape(self) -> "ChatMessage":
if self.tool_calls is not None and self.role != "assistant":
raise ValueError('"tool_calls" is only valid on role="assistant" messages.')
if self.tool_call_id is not None and self.role != "tool":
raise ValueError('"tool_call_id" is only valid on role="tool" messages.')
if self.name is not None and self.role != "tool":
raise ValueError('"name" is only valid on role="tool" messages.')
if self.role == "tool":
# tool_call_id resolution happens at ChatCompletionRequest scope.
# OpenAI accepts empty tool results (commands with no output);
# normalize to "" instead of a 400 agentic clients treat as fatal.
if self.content is None or self.content == []:
self.content = ""
elif self.role == "assistant":
# Post-Stop sentinel: collapse content="" / [] to None.
if (self.content == "" or self.content == []) and not self.tool_calls:
self.content = None
else: # "user" | "system"
if self.content is None or self.content == []:
raise ValueError(f'role="{self.role}" messages require "content".')View on GitHub (pinned to 203007d190)
Solutions
- Set role="assistant" on any message that carries tool_calls.
- If demonstrating a tool call in a prompt, put it in message content as text, not the tool_calls field.
- Fix the transcript builder so tool_calls stays attached to the assistant turn it came from.
Example fix
# before
messages = [
{"role": "user", "content": "run ls"},
{"role": "user", "tool_calls": [{"id": "c1", "type": "function", "function": {...}}]},
]
# after
messages = [
{"role": "user", "content": "run ls"},
{"role": "assistant", "tool_calls": [{"id": "c1", "type": "function", "function": {...}}]},
] Defensive patterns
Strategy: type-guard
Type guard
def message_ok(msg: dict) -> bool:
if 'tool_calls' in msg and msg.get('tool_calls') is not None:
return msg.get('role') == 'assistant'
return True Prevention
- Only the model's own turn carries tool_calls; keep it role=assistant
- When replaying transcripts, deep-copy the whole turn instead of mutating role
- Describe example tool calls in content text, not the tool_calls field
When it happens
Trigger: POST /v1/chat/completions with {"role": "user", "tool_calls": [...]} or {"role": "system", "tool_calls": [...]}. Also happens when replaying a captured assistant turn but overwriting role in client code.
Common situations: Agentic clients that append the model's tool-call turn with the wrong role after a copy/paste; prompt-templates that inject tool examples into user messages using the tool_calls key instead of prose; transcript serializers that attach tool_calls to every message in a thread.
Related errors
- "tool_call_id" is only valid on role="tool" messages.
- "name" is only valid on role="tool" messages.
- role="{self.role}" messages require "content".
- Provide either content_base64 or file_ids, not both
- Provide either content_base64 or file_ids
AI-assisted analysis of unslothai/unsloth@203007d190 (2026-08-15).
Data as JSON: /api/errors/84c2f3d36e51f58f.
Report an issue: GitHub.