Skip to content

fix: accept system-role messages in /v1/messages (#2193)#2201

Open
oliver0006 wants to merge 1 commit into
exo-explore:mainfrom
oliver0006:fix/claude-system-role-messages
Open

fix: accept system-role messages in /v1/messages (#2193)#2201
oliver0006 wants to merge 1 commit into
exo-explore:mainfrom
oliver0006:fix/claude-system-role-messages

Conversation

@oliver0006

Copy link
Copy Markdown

Motivation

Fixes #2193.

/v1/messages rejected any request carrying a system-role entry inside messages with a 422 (ClaudeRole = Literal["user", "assistant"]). Strictly correct per Anthropic's spec, but real-world Anthropic-format clients — notably Claude Code pointed at exo via ANTHROPIC_BASE_URL, the most common reason this endpoint exists — do send system-role entries in messages, so the endpoint was unusable for exactly its target clients. This implements the lenient-adapter direction proposed by @batmunkh0612 in the issue (thanks for the precise report and repro!).

Changes

  • ClaudeRole accepts "system" (request-side only — response types have their own literals).
  • claude_request_to_text_generation() folds system-role messages into the system prompt: the top-level system field keeps its exact existing semantics, in-band system messages are appended after it (joined with blank lines), and the combined text goes through the existing _strip_volatile_headers() (which was already built for Claude Code's volatile billing headers). System entries are removed from the conversation before conversion, preserving message order.

Why It Works

The 422 came from pydantic validation of the request model, before any handler logic ran. Widening the request literal makes validation pass, and folding happens entirely inside the request→TextGenerationTaskParams adapter, so nothing downstream changes: the internal MessageRole already includes "system", and a request without in-band system messages produces byte-identical results to before.

Test Plan

Manual Testing

Hardware: Fedora Linux (16-thread Ryzen). Launched uv run exo and replayed the exact curl from the issue:

  • Before: 422 {"detail":[{"type":"literal_error",..."msg":"Input should be 'user' or 'assistant'","input":"system"}]}
  • After: request passes validation and reaches instance lookup (404 No instance found for model ... with no model loaded — i.e. it would generate normally with one).

Automated Testing

New TestSystemRoleMessages class in test_claude_api.py:

  • the issue's exact payload passes request validation
  • a system-role message becomes instructions and is excluded from input
  • in-band system merges after the top-level system field
  • system message with text-block content
  • conversation order preserved for the remaining user/assistant messages

All 20 tests in test_claude_api.py pass (including the pre-existing top-level-system block-joining semantics, which are unchanged); full uv run pytest src → 344 passed. uv run basedpyright 0 errors, ruff check and ruff format --check pass.


Disclosure: co-written by a human (@oliver0006) directing the work and Claude AI (Fable 5) writing code — which makes this a fix Claude wrote so Claude Code can run on exo. The loop closes itself.

🤖 Generated with Claude Code

The Anthropic-compatible endpoint rejected any request with a
system-role entry inside messages with a 422, which made it unusable
for Claude Code pointed at exo via ANTHROPIC_BASE_URL. ClaudeRole now
accepts "system" and such messages are folded into the system prompt
(joined after the top-level system field) before conversion, as
suggested in the issue.

Fixes exo-explore#2193

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/v1/messages rejects system-role messages (422) — Claude Code can't use exo as ANTHROPIC_BASE_URL

1 participant