Learn how a 200-line proxy fixed a strict role-alternation bug that broke Mistral Small 4 after the first few turns

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/.

Flow

Fix Mistral Role Error

Proxy-based solution for OpenClaw + SGLang integration

1
Problem OpenClaw sends non-alternating roles to SGLang
2
Diagnosis Mistral's strict role alternation violated
3
Proxy Layer Python proxy merges same-role messages
4
Routing OpenClaw → Proxy → SGLang
5
Result Valid alternating roles restored