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

  1. Put tool_call_id only on the role="tool" message that answers the call.
  2. Keep the matching id on the assistant turn's tool_calls entry so the pair correlates.
  3. 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

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


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