Fix OpenClaw + SGLang with Mistral: Stop the "conversation roles must alternate" 400 BadRequest
OpenClaw’s chat completions to SGLang worked fine for the first few turns, then died with a silent 400 BadRequest.
Quick Take
- OpenClaw sends chat completions that break Mistral’s strict role alternation after compaction or tool calls
- SGLang returns a 400 with no body, making debugging painful
- A tiny proxy merges consecutive same-role messages and restores the conversation flow
- Total fix time: one afternoon, zero OpenClaw changes
The Silent 400 BadRequest
OpenClaw talks to SGLang via the OpenAI-compatible /v1/chat/completions endpoint. Mistral’s chat template (mistral_v3) enforces strict alternation after an optional system message:
system → user → assistant → user → assistant → ...
OpenClaw, after compaction or tool-call routing, sometimes stacks two user or two assistant messages. SGLang rejects them with:
{
"object": "error",
"message": "After the optional system message, conversation roles must alternate user and assistant roles except for tool calls and results.",
"type": "BadRequest",
"code": 400
}
OpenClaw logs only show “400 status code (no body)” because the response body gets stripped. That made the bug hard to spot until the matrix bot stopped responding after the first few turns.
Why Strict Alternation Breaks Out of the Box
OpenClaw v2026.4.24 has no built-in role normalization for providers that enforce strict alternation. Anthropic, OpenAI, and Bedrock accept same-role sequences without complaint, so the bug only appears with Mistral or Llama-3-style templates. The openai-completions API mode routes directly to /v1/chat/completions with the raw message array, leaving role alternation unchecked.
For example, last week this failed because OpenClaw compacted an old conversation into a single user message, then appended a new user message from a tool result. SGLang rejected it immediately.
The Fix: A Side-Car Proxy That Merges Same-Role Messages
The fix is a small Python proxy between OpenClaw and SGLang. It parses requests to /v1/chat/completions, merges consecutive same-role messages into one, and forwards the rest unchanged. Streaming responses pass through chunk-by-chunk without buffering.
OpenClaw (18789) → openclaw-mistral-proxy (30099) → SGLang (30000)
Here’s the core function:
def merge_alternating(messages):
if not messages:
return messages
out = []
for m in messages:
role = m.get("role")
if not out:
out.append(dict(m))
continue
prev = out[-1]
prev_role = prev.get("role")
if role in ("system", "tool") or prev_role in ("system", "tool"):
out.append(dict(m))
continue
if role != prev_role:
out.append(dict(m))
continue
# Same role: merge content
pc, mc = prev.get("content"), m.get("content")
if isinstance(pc, str) and isinstance(mc, str):
prev["content"] = pc + "\n\n" + mc
elif isinstance(pc, list) and isinstance(mc, list):
prev["content"] = pc + mc
# ... handle other combinations
return out
This is the same trick used by the vibe-patch v4 for Mistral CLI to handle strict alternation.
Deploying the Proxy as a Systemd Service
Create a user service to run the proxy:
~/.config/systemd/user/openclaw-mistral-proxy.service
[Unit]
Description=OpenClaw → SGLang Mistral alternating-roles proxy
After=network.target
Before=openclaw-gateway.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /scripts/openclaw-mistral-proxy.py
Restart=always
RestartSec=3
Environment="OPENCLAW_PROXY_LOG=%h/.openclaw/mistral-proxy.log"
[Install]
WantedBy=default.target
Add a drop-in to make OpenClaw wait for the proxy:
~/.config/systemd/user/openclaw-gateway.service.d/proxy-dep.conf
[Unit]
Requires=openclaw-mistral-proxy.service
After=openclaw-mistral-proxy.service
Update OpenClaw’s provider config to point to the proxy instead of SGLang directly:
{
"models": {
"providers": {
"sglang": {
"baseUrl": "http://127.0.0.1:30099/v1",
"apiKey": "sk-sglang",
"api": "openai-completions",
"models": [{ "id": "Mistral-Small-4", ... }]
}
}
}
}
Activate the proxy and restart OpenClaw:
systemctl --user daemon-reload
systemctl --user enable --now openclaw-mistral-proxy.service
systemctl --user restart openclaw-gateway.service
Validation: Conversations Survive Compaction
With the proxy in place, conversations run smoothly even after compaction:
[proxy] OK passthrough msgs=12 path=/v1/chat/completions
[proxy] OK merged 18->17 msgs path=/v1/chat/completions
OpenClaw logs confirm success:
[agent/embedded] embedded run agent end: ... isError=false
Removing the Proxy When Upstream Fixes Role Normalization
When OpenClaw adds role normalization for openai-completions or a provider-specific flag like messageNormalization: "alternating", the proxy can be removed:
systemctl --user disable --now openclaw-mistral-proxy.service
systemctl --user restart opencllaw-gateway.service
What I Actually Use
- Mistral Small 4: the model that exposed the strict alternation bug
- SGLang serving stack: the inference server that enforces the template rules
Why a Side-Car-Proxy is the right shape for this class of fix
Three approaches existed for resolving the alternating-roles BadRequestError between OpenClaw and SGLang/Mistral: patch OpenClaw’s request builder upstream, patch the SGLang acceptance rules, or insert a small proxy that rewrites the request body in-flight. Only the proxy approach is reversible, framework-agnostic, and ships without waiting for either upstream to merge a PR.
The Side-Car-Proxy is roughly fifty lines of Python that inspects each chat/completions request, collapses adjacent same-role messages, and forwards the cleaned payload to SGLang on the unchanged port. The OpenClaw side does not know it exists. The SGLang side does not know it exists. If we ever switch to a different framework (or upstream finally fixes the role-ordering issue), the proxy goes away with one systemd-unit removal and zero rollback risk.
This is the same shape as the OpenHands-side enable_prompt_extensions = false workaround: minimum-viable adapter at the boundary, not a deep rewrite of either side. Worth remembering as a pattern: when two opinionated systems disagree at their interface, a small in-flight rewriter is almost always the right first attempt.
Upstream status
Filed as a feature request at the OpenClaw tracker on 2026-05-04: issue #77336. The body proposes two in-repo approaches (a requires_strict_alternation provider capability flag, or a pre-send hook on the Mistral provider plugin that merges consecutive same-role messages) and offers a PR for whichever direction maintainers indicate. The Side-Car-Proxy stays in production until upstream lands a built-in. Full list of contributions: /upstream/.
Fix Mistral Role Error
Proxy-based solution for OpenClaw + SGLang integration