{"record":{"id":"ce769adfd6ac971b","repo":"BerriAI/litellm","slug":"content-must-be-a-string","errorCode":null,"errorMessage":"Content must be a string","messagePattern":"Content must be a string","errorType":"exception","errorClass":"Exception","httpStatus":null,"severity":"error","filePath":"litellm/llms/bytez/chat/transformation.py","lineNumber":446,"sourceCode":"        if isinstance(content, str):\n            new_content.append({\"type\": \"text\", \"text\": content})\n\n        elif isinstance(content, dict):\n            new_content.append(content)\n\n        elif isinstance(content, list):\n            new_content_items = []\n            for content_item in content:\n                if isinstance(content_item, str):\n                    new_content_items.append({\"type\": \"text\", \"text\": content_item})\n                elif isinstance(content_item, dict):\n                    new_content_items.append(content_item)\n                else:\n                    raise Exception(\"`content` can only contain strings or openai content dicts\")\n\n            new_content += new_content_items\n        else:\n            raise Exception(\"Content must be a string\")\n\n        new_messages.append({\"role\": role, \"content\": new_content})\n\n    return new_messages\n\n\n# TODO get this from the api instead of doing it here, will require backend work\ndef get_tokens_from_messages(messages: list[dict]):\n    total = 0\n\n    for message in messages:\n        content: list[dict] = message[\"content\"]\n\n        for content_item in content:\n            type = content_item[\"type\"]\n            if type == \"text\":\n                value: str = content_item[\"text\"]\n                words = value.split(\" \")","sourceCodeStart":428,"sourceCodeEnd":464,"githubUrl":"https://github.com/BerriAI/litellm/blob/77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8/litellm/llms/bytez/chat/transformation.py#L428-L464","documentation":"Thrown by litellm's Bytez adapter in _adapt_string_only_content_to_lists (litellm/llms/bytez/chat/transformation.py:446) when a message's content is neither a str, a dict, nor a list. The Bytez API expects content as an OpenAI-style list of parts, so the adapter normalizes strings/dicts/lists and rejects anything else. In practice this means content was None, a number, or some other object. Assistant messages with tool_calls frequently carry content: null in OpenAI-shaped payloads and trigger it.","triggerScenarios":"Calling completion() with a bytez/* model and a message whose content is None (e.g. an assistant tool_call history entry with content omitted), an int/float, or any non str/dict/list object. Also triggered when a list item in content is not a string or dict (sibling error at line 442).","commonSituations":"Replaying OpenAI chat histories that contain {\"role\": \"assistant\", \"content\": null, \"tool_calls\": [...]} into a bytez model; building messages dynamically and leaving content unset; sending pre-formatted or non-OpenAI message shapes; agents that serialize tool output into non-string content fields.","solutions":["Set every message's content to a string, an OpenAI content-part dict, or a list of those — replace content: null with \"\" on assistant/tool-call messages","If you pass a list, ensure every element is either a plain string or an OpenAI content dict like {\"type\": \"text\", \"text\": \"...\"}","Sanitize the message list right before the call (see validation snippet) and drop or stringify None values","If you need tool-calling semantics, verify the Bytez provider actually supports tool_calls before forwarding assistant history"],"exampleFix":"# before\nmessages = [\n    {\"role\": \"user\", \"content\": \"hi\"},\n    {\"role\": \"assistant\", \"content\": None, \"tool_calls\": [...]},\n]\nlitellm.completion(model=\"bytez/your-model\", messages=messages)\n\n# after\nmessages = [\n    {\"role\": \"user\", \"content\": \"hi\"},\n    {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [...]},\n]\nlitellm.completion(model=\"bytez/your-model\", messages=messages)","handlingStrategy":"type-guard","validationCode":"def has_valid_bytez_content(messages):\n    for i, m in enumerate(messages):\n        c = m.get(\"content\")\n        if not isinstance(c, (str, dict, list)):\n            return False, f\"messages[{i}] content is {type(c).__name__}\"\n        if isinstance(c, list):\n            for j, part in enumerate(c):\n                if not isinstance(part, (str, dict)):\n                    return False, f\"messages[{i}].content[{j}] is {type(part).__name__}\"\n    return True, None\n\nok, why = has_valid_bytez_content(messages)\nif not ok:\n    raise ValueError(f\"Bytez request invalid: {why}\")","typeGuard":"from typing import Any\n\ndef is_valid_message_content(content: Any) -> bool:\n    \"\"\"True when Bytez accepts the content value (str, dict, or list of str/dict).\"\"\"\n    if isinstance(content, (str, dict)):\n        return True\n    if isinstance(content, list):\n        return all(isinstance(p, (str, dict)) for p in content)\n    return False  # includes None","tryCatchPattern":"try:\n    litellm.completion(model=\"bytez/...\", messages=messages)\nexcept Exception as e:\n    if \"Content must be a string\" in str(e):\n        messages = [\n            {**m, \"content\": m.get(\"content\") if m.get(\"content\") is not None else \"\"}\n            for m in messages\n        ]\n        litellm.completion(model=\"bytez/...\", messages=messages)\n    else:\n        raise","preventionTips":["Normalize assistant/tool-call history entries to content: \"\" before sending to bytez models","Validate message shape once at your app boundary instead of per call site","Keep message construction in one helper so content is always typed str | list[dict]"],"tags":["bytez","message-content","input-validation","openai-compat"],"backgroundTag":"message-content-validation","analyzedSha":"77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8","analyzedAt":"2026-08-18T11:44:31.656Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}