{"record":{"id":"84c2f3d36e51f58f","repo":"unslothai/unsloth","slug":"tool-calls-is-only-valid-on-role-assistant-mes","errorCode":null,"errorMessage":"\"tool_calls\" is only valid on role=\"assistant\" messages.","messagePattern":"\"tool_calls\" is only valid on role=\"assistant\" messages\\.","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"studio/backend/models/inference.py","lineNumber":1256,"sourceCode":"        description = (\n            \"Provider-specific extra fields the translator may read. \"\n            \"Gemini reads `extra_content.google.thought_signature` \"\n            \"from assistant messages to replay text-part signatures.\"\n        ),\n    )\n\n    @field_validator(\"reasoning_content\", mode = \"before\")\n    @classmethod\n    def _ignore_non_string_reasoning(cls, value):\n        # This field used to be ignored as an unknown key. Some compatible\n        # gateways send structured reasoning, so declaring the string form must\n        # not turn those previously accepted requests into validation errors.\n        return value if isinstance(value, str) else None\n\n    @model_validator(mode = \"after\")\n    def _validate_role_shape(self) -> \"ChatMessage\":\n        if self.tool_calls is not None and self.role != \"assistant\":\n            raise ValueError('\"tool_calls\" is only valid on role=\"assistant\" messages.')\n        if self.tool_call_id is not None and self.role != \"tool\":\n            raise ValueError('\"tool_call_id\" is only valid on role=\"tool\" messages.')\n        if self.name is not None and self.role != \"tool\":\n            raise ValueError('\"name\" is only valid on role=\"tool\" messages.')\n\n        if self.role == \"tool\":\n            # tool_call_id resolution happens at ChatCompletionRequest scope.\n            # OpenAI accepts empty tool results (commands with no output);\n            # normalize to \"\" instead of a 400 agentic clients treat as fatal.\n            if self.content is None or self.content == []:\n                self.content = \"\"\n        elif self.role == \"assistant\":\n            # Post-Stop sentinel: collapse content=\"\" / [] to None.\n            if (self.content == \"\" or self.content == []) and not self.tool_calls:\n                self.content = None\n        else:  # \"user\" | \"system\"\n            if self.content is None or self.content == []:\n                raise ValueError(f'role=\"{self.role}\" messages require \"content\".')","sourceCodeStart":1238,"sourceCodeEnd":1274,"githubUrl":"https://github.com/unslothai/unsloth/blob/203007d19051dcd2ae33876786d117c99f6b0368/studio/backend/models/inference.py#L1238-L1274","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before\nmessages = [\n    {\"role\": \"user\", \"content\": \"run ls\"},\n    {\"role\": \"user\", \"tool_calls\": [{\"id\": \"c1\", \"type\": \"function\", \"function\": {...}}]},\n]\n\n# after\nmessages = [\n    {\"role\": \"user\", \"content\": \"run ls\"},\n    {\"role\": \"assistant\", \"tool_calls\": [{\"id\": \"c1\", \"type\": \"function\", \"function\": {...}}]},\n]","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"def message_ok(msg: dict) -> bool:\n    if 'tool_calls' in msg and msg.get('tool_calls') is not None:\n        return msg.get('role') == 'assistant'\n    return True","tryCatchPattern":null,"preventionTips":["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"],"tags":["pydantic","validation","openai","chat-completions","tool-calls","agents"],"backgroundTag":null,"analyzedSha":"203007d19051dcd2ae33876786d117c99f6b0368","analyzedAt":"2026-08-15T02:48:39.846Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}