DEV Community

MilkyWay008
MilkyWay008

Posted on

HTTP 400 from your own LLM endpoint? Check for content: null

Someone I was helping had a tidy little setup: a self-hosted OpenAI-compatible endpoint in front of their model, a coding agent pointed at it, months of smooth sailing. Then they bumped the agent from 7.3.42 to 7.3.44 and every request came back HTTP 400. Same endpoint, same key, same model. Only the client version changed.

The error body was short, and it looked roughly like this:

400 Bad Request
{"error": {"message": "Type validation failed",
           "path": "/messages/2/content",
           "keyword": "invalid_union"}}
Enter fullscreen mode Exit fullscreen mode

If you run a gateway in front of a local model (vLLM, LiteLLM, llama.cpp, Open WebUI, LM Studio, or your own FastAPI shim), this one will find you eventually. The error already tells you where to look, and the fix is usually a couple of lines on whichever side of the wire you control.

The field it names is the whole clue

/messages/2/content means the third message in the array, its content field, and a union check that failed. Validators generated from a schema (pydantic, zod, ajv and friends) hand you the JSON path for free. Read the path first, then read the raw request, and stop guessing about the model.

Log the outgoing body at the gateway (raw JSON, not a redacted summary), find that index, and look at the message. In this case it was an assistant message with tool calls and no text:

{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {"id": "call_1", "type": "function",
     "function": {"name": "read_file", "arguments": "{\"path\": \"a.txt\"}"}}
  ]
}
Enter fullscreen mode Exit fullscreen mode

Nothing looks broken there. That's the trap.

What changed, and why neither side is crazy

On the client side, Vercel's AI SDK changed tool-only assistant messages from content: "" to content: null on purpose. The commit message spells out why (vercel/ai@bfb756d8, April 2026): providers backed by AWS Bedrock reject an empty text block with ValidationException: text content blocks must be non-empty, so the serializer became content: text || null. A lot of coding agents sit on top of that SDK, which is why the change showed up in several places at once.

On the server side, the endpoint's validator insists that an assistant content is a string. Null isn't a string. Union check fails, request dies.

OpenAI's own schema allows null, though. In the OpenAI OpenAPI spec, the assistant message defines content as string | array of parts | null, described as "The contents of the assistant message. Required unless tool_calls or function_call is specified." The only required property on that object is role.

So content: null on a tool-call-only assistant message is legal, and the validator is the piece that's out of step with the protocol. "My schema says no" is a different sentence from "the spec says no."

Fixing it

If you own the endpoint, normalize at the edge. Accept null when tool calls are present, and rewrite it into something your backend tolerates. A small piece of Starlette middleware is enough:

import json
from starlette.middleware.base import BaseHTTPMiddleware

def normalize(body):
    for m in body.get("messages", []):
        if m.get("role") == "assistant" and m.get("tool_calls") and m.get("content") is None:
            m["content"] = ""   # or " " if your backend also dislikes empty strings
    return body

class NullContentFix(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        if request.url.path.endswith("/chat/completions"):
            payload = await request.json()
            request._body = json.dumps(normalize(payload)).encode()
        return await call_next(request)
Enter fullscreen mode Exit fullscreen mode

That comment about empty strings isn't hypothetical. Continue's OpenAI converter does message.content || " " with the literal comment "LM Studio (and other providers) don't accept empty content." Different clients picked different workarounds for the same edge case, which is a decent hint about how long this has been rattling around. If your backend is picky about empty strings too, coerce to a single space.

If you don't own the endpoint, pin the client. The actual difference between working and broken was @ai-sdk/openai-compatible 2.0.41 versus 2.0.48. A sibling tool still sitting on 2.0.41 was reported working against the same endpoint, which is a reminder that "it works in tool X" says nothing about your config. Pin it in the lockfile, or put your own proxy in the middle to rewrite null.

And don't wait on the upstream fix. A normalizing pull request for the client was opened and closed without merging (kilocode#12331; the original report is kilocode#12330), so as of today it's on you to pin or normalize.

The same 400 in other disguises

Once you've seen one message-shape mismatch, you spot them everywhere. The ones I keep running into:

  • Reasoning blocks that don't survive a round trip. Open WebUI dropped the Anthropic thinking-block signature when saving reasoning_details, then replayed the unsigned block on the next turn. Anthropic rejects it with Invalid signature in thinking block, so turn 2 dies every time, identically across five providers (open-webui#27467). The fix PR is closed without merging, so stripping unsigned reasoning from prior assistant turns in a proxy is the workaround for now.
  • Streaming reasoning where delta.content isn't a string. n8n 2.36.8 with the Mistral Chat Model: content arrives as an array of thinking blocks, the node's schema wants a string, and you get invalid_union. Routing through a proxy that strips thinking deltas worked (n8n#37352, which n8n closed on Sep 8).
  • Extra parameters the client started injecting. One client began sending prompt_cache_breakpoint to a custom Responses endpoint and got 400 invalid_parameter on every request, reproducing on a clean VM (kilocode#13285). Strip the unknown field at the proxy, or pin the client back.
  • Role remapping that breaks the model's chat template. LM Studio maps each developer message to its own system message, so the second one lands mid-prompt and Qwen's Jinja template throws System message must be at the beginning (lmstudio#2298). Consolidate developer messages client-side, or fall back to /v1/chat/completions.

The habit that saves the evening

When a proxy or router sits in the middle, the 400 is often produced by the translation layer, not the model. Test with tools disabled, try the same model through a different route, and read the JSON path in the error before touching anything else.

Then decide who owns the field, fix the side you control, and pin the version of the thing that changed instead of the whole stack.

And if you happen to be the one writing the validator: accept null on assistant messages that carry tool calls. It's in the spec.

I've only hit this on a handful of stacks, so treat it as a starting point rather than gospel, and test it against your own gateway. I keep notes on this class of failure in a small KB repo, Windows-flavoured more often than not: hermes-kb-hack-fix. Hope it saves you an evening.

Top comments (0)