unslothai/unsloth · error · ValueError
"tool_call_id" is only valid on role="tool" messages.
Error message
"tool_call_id" is only valid on role="tool" messages.
What it means
Raised by the ChatMessage model validator when tool_call_id is present but role is not "tool". tool_call_id is the correlation key that links a tool result message back to the assistant's specific tool call, so it is only valid on role="tool" messages.
Source
Thrown at studio/backend/models/inference.py:1258
"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".')
return self
View on GitHub (pinned to 203007d190)
Solutions
- Put tool_call_id only on the role="tool" message that answers the call.
- Keep the matching id on the assistant turn's tool_calls entry so the pair correlates.
- Strip tool_call_id when logging/replaying messages outside a tool-result turn.
Example fix
# before
messages.append({"role": "assistant", "tool_call_id": "call_123", "content": tool_output})
# after
messages.append({"role": "assistant", "tool_calls": [{"id": "call_123", "type": "function", "function": {...}}]})
messages.append({"role": "tool", "tool_call_id": "call_123", "content": tool_output}) Defensive patterns
Strategy: type-guard
Type guard
def message_ok(msg: dict) -> bool:
if msg.get('tool_call_id') is not None:
return msg.get('role') == 'tool'
return True Prevention
- Pair every tool_call_id with the assistant turn's matching call id
- Build tool replies with a helper that always sets role='tool'
- Strip correlation ids when logging messages outside tool-result turns
When it happens
Trigger: Sending {"role": "assistant", "tool_call_id": "call_123", "content": "..."} or a user message with tool_call_id to /v1/chat/completions. Typically a client that echoes the id on the wrong turn when building the tool-result reply.
Common situations: Agents that build the tool response but forget to switch role from assistant to tool; copy/paste of the assistant tool_calls turn with only the id renamed; templates that include tool_call_id on system messages as 'context'.
Related errors
- "tool_calls" is only valid on role="assistant" 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/2aca340e7b1e1523.
Report an issue: GitHub.