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

  1. Set role="assistant" on any message that carries tool_calls.
  2. If demonstrating a tool call in a prompt, put it in message content as text, not the tool_calls field.
  3. 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

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


AI-assisted analysis of unslothai/unsloth@203007d190 (2026-08-15). Data as JSON: /api/errors/84c2f3d36e51f58f. Report an issue: GitHub.