Studio /v1/messages: accept thinking and unknown content blocks (#7017)

* Studio /v1/messages: accept thinking and unknown content blocks

The Anthropic-compatible /v1/messages endpoint modeled a message's content as
Union[str, list[{text|image|tool_use|tool_result}]], so any other block type
made Pydantic reject the whole request with
`messages.N.content.str: Input should be a valid string`. Resuming a Claude
session commonly replays assistant turns that carry `thinking` (extended
thinking) blocks, and sometimes a null content for a tool-only turn, both of
which tripped this and returned a 400.

Accept them:
- Add a permissive AnthropicUnknownBlock fallback (any block whose type is not
  one of the four known ones), so thinking/redacted_thinking/provider-specific/
  future blocks validate. A validator keeps known types on their typed models,
  so a malformed known block (e.g. a tool_use without id) still fails cleanly.
- Coerce a null message (and tool_result) content to "" so the converter's
  `for block in content` stays safe.

The converter already drops block types it does not translate, so a thinking
block is not forwarded to the model.

* Studio /v1/messages: keep user content validation strict

Make the thinking/null leniency role-aware so it never silently drops real
user input. Assistant turns (replayed history) still accept unknown/thinking
blocks and coerce a null tool-only turn to empty. User turns keep the strict
boundary: a null user content is rejected, and a content block the converter
cannot translate is rejected instead of being dropped into an empty prompt.

Also remove an empty file committed by accident.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio /v1/messages: coalesce resumed user turns and tighten content checks

- The /v1/messages count and generation paths now coalesce the adjacent user
  turns that dropping an empty or null assistant turn can leave behind, so a
  strict GGUF chat template no longer 400s on non-alternating roles.
- A user content block with a non-string type (list / dict) is rejected as a
  clean 400 instead of raising TypeError and escaping as a 500.
- The assistant null-to-empty coercion only applies to an explicit null; an
  assistant turn that omits content entirely still fails required-field
  validation instead of being silently coerced to an empty string.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio /v1/messages: tighten comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-09 03:20:02 -07:00 committed by GitHub
parent c1e06e9ddf
commit eb775d3207
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 257 additions and 4 deletions

View file

@ -1533,12 +1533,41 @@ class AnthropicToolResultBlock(BaseModel):
tool_use_id: str
content: Union[str, list] = ""
@field_validator("content", mode = "before")
@classmethod
def _coerce_null_content(cls, v):
# Some clients send null content for an empty tool result; the str|list
# union would 400 on it, so treat null as "".
return "" if v is None else v
# Block types the converter translates explicitly. Anything else (thinking /
# redacted_thinking, a provider block a resumed session replays, or a future type)
# is accepted as an unknown block and dropped by the converter, rather than 400-ing
# the whole request on strict validation.
_KNOWN_ANTHROPIC_BLOCK_TYPES = frozenset({"text", "image", "tool_use", "tool_result"})
class AnthropicUnknownBlock(BaseModel):
type: str
model_config = {"extra": "allow"}
@field_validator("type")
@classmethod
def _only_unknown_types(cls, v):
# Known types parse as their typed models above (so a malformed known block
# still fails cleanly); this fallback only catches the rest.
if v in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError("known block type handled by its typed model")
return v
AnthropicContentBlock = Union[
AnthropicTextBlock,
AnthropicImageBlock,
AnthropicToolUseBlock,
AnthropicToolResultBlock,
AnthropicUnknownBlock,
]
@ -1583,6 +1612,40 @@ class AnthropicMessage(BaseModel):
role: Literal["user", "assistant"]
content: Union[str, list[AnthropicContentBlock]]
@model_validator(mode = "before")
@classmethod
def _normalize_content(cls, data):
# Role-aware leniency that never silently drops real user input:
# - assistant: a resumed tool-only turn's null content -> "" (str|list would
# 400 on null; "" keeps the converter's `for block in content` safe).
# Unknown blocks (thinking / future types) validate via
# AnthropicUnknownBlock and are dropped by the converter.
# - user: keep strict. Null user content stays None so str|list rejects it
# (400) rather than forwarding an empty prompt; and reject block types the
# converter cannot translate, since it silently skips unknown user blocks
# -- a user turn made only of them would validate yet send no content
# (silent data loss).
if not isinstance(data, dict):
return data
content = data.get("content")
if data.get("role") == "assistant":
# Coerce only an explicit null (resumed tool-only turn). A missing
# content key stays malformed so the required-field check still 400s.
if "content" in data and content is None:
return {**data, "content": ""}
return data
if isinstance(content, list):
for block in content:
btype = (
block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
)
# Guard the value: a non-string type is unsupported too, and a
# membership test on an unhashable value would raise TypeError
# (escaping as a 500 instead of a clean 400).
if not isinstance(btype, str) or btype not in _KNOWN_ANTHROPIC_BLOCK_TYPES:
raise ValueError(f"unsupported content block type {btype!r} in a user message")
return data
class AnthropicTool(BaseModel):
# Client tools have input_schema; server tools may only have type/name.

View file

@ -10088,8 +10088,11 @@ async def anthropic_count_tokens(
# Apply the same sanitization /messages does before generation, so the count
# matches the prompt the real request would build (otherwise empty-assistant
# sentinels / synthetic tool history inflate the count or hit the fallback).
openai_messages = _strip_provider_synthetic_tool_history(
_drop_empty_assistant_sentinels(openai_messages)
# Coalesce adjacent user turns left behind by dropping an empty / null assistant
# turn, so a strict GGUF chat template does not 400 on non-alternating roles
# (mirrors the GGUF chat path); a no-op for already-alternating histories.
openai_messages = _coalesce_consecutive_user_turns(
_strip_provider_synthetic_tool_history(_drop_empty_assistant_sentinels(openai_messages))
)
openai_tools = anthropic_tools_to_openai(payload.tools or []) or None
@ -10217,8 +10220,11 @@ async def anthropic_messages(
# builders apply the same strip; without it an Anthropic /v1/messages caller
# replaying a prior provider-side tool_use forwards fake builtin tool
# history to a backend with no matching function declarations.
openai_messages = _strip_provider_synthetic_tool_history(
_drop_empty_assistant_sentinels(openai_messages)
# Coalesce adjacent user turns left behind by dropping an empty / null assistant
# turn, so a strict GGUF chat template does not 400 on non-alternating roles
# (mirrors the GGUF chat path); a no-op for already-alternating histories.
openai_messages = _coalesce_consecutive_user_turns(
_strip_provider_synthetic_tool_history(_drop_empty_assistant_sentinels(openai_messages))
)
# Enforce vision guard + re-encode embedded images to PNG so the Anthropic

View file

@ -1770,3 +1770,187 @@ class TestAnthropicMessagesToolRouting:
_drive(anthropic_messages(payload, request = None, current_subject = "t"))
assert backend.calls[0][0] == "plain"
def test_resumed_session_thinking_and_null_content_do_not_400():
# A resumed session replays assistant turns with `thinking` (and sometimes null)
# content. Those must be accepted (thinking dropped by the converter), not 400ed.
from pydantic import ValidationError
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "secret reasoning", "signature": "s"},
{"type": "text", "text": "the answer"},
{"type": "tool_use", "id": "t1", "name": "f", "input": {}},
],
},
{"role": "assistant", "content": None}, # tool-only turn serialized as null
],
)
# Known blocks still parse as their typed models; only the unknown one is loose.
assert type(req.messages[1].content[0]).__name__ == "AnthropicUnknownBlock"
assert type(req.messages[1].content[1]).__name__ == "AnthropicTextBlock"
assert req.messages[2].content == "" # null coerced
openai = anthropic_messages_to_openai([m.model_dump() for m in req.messages])
assistant = next(m for m in openai if m["role"] == "assistant" and m.get("content"))
assert assistant["content"] == "the answer"
assert "secret reasoning" not in json.dumps(openai) # thinking never forwarded
# A malformed KNOWN block still fails cleanly instead of being swallowed.
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "assistant", "content": [{"type": "tool_use", "name": "f"}]}],
)
def test_user_null_content_rejected():
# The null->"" leniency is assistant-only; a null user content must be rejected
# at the boundary, not coerced into an empty prompt and forwarded to the model.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "user", "content": None}],
)
def test_user_unknown_block_rejected_not_silently_dropped():
# The converter skips user blocks it cannot translate, so a user turn whose only
# block is unknown would validate yet forward no content. Reject at the boundary
# to avoid that silent data loss (the assistant fallback is unaffected).
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": [{"type": "document", "source": {}}]},
],
)
def test_user_translatable_blocks_still_accepted():
# text / image / tool_result are translatable, so a real user message built from
# them must still pass; the unknown-block guard only trips on other types.
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "AA"},
},
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"},
],
}
],
)
assert [type(b).__name__ for b in req.messages[0].content] == [
"AnthropicTextBlock",
"AnthropicImageBlock",
"AnthropicToolResultBlock",
]
openai = anthropic_messages_to_openai([m.model_dump() for m in req.messages])
assert any(m["role"] == "tool" and m["tool_call_id"] == "t1" for m in openai)
def test_user_malformed_known_block_still_rejected():
# The guard only allow-lists a user block's *type*; the union still validates its
# shape, so a known-but-malformed block (tool_result without tool_use_id) fails.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": [{"type": "tool_result", "content": "x"}]},
],
)
def test_user_content_block_non_string_type_rejected_cleanly():
# A user block whose `type` is a non-string (unhashable list / dict, or a stray
# int) must fail as a clean validation error, not raise TypeError from the
# frozenset membership test and escape as a 500.
from pydantic import ValidationError
for bad_type in ([], {}, 5):
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "user", "content": [{"type": bad_type}]}],
)
def test_assistant_missing_content_key_still_rejected():
# The null -> "" leniency is only for an EXPLICIT null. An assistant message that
# omits content entirely stays malformed and must fail required-field validation.
from pydantic import ValidationError
with pytest.raises(ValidationError):
AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [{"role": "assistant"}],
)
# An explicit null is still accepted and coerced (regression guard).
req = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None},
],
)
assert req.messages[1].content == ""
def test_resumed_null_assistant_between_users_coalesced_on_messages_route(monkeypatch):
# user -> assistant(null) -> user is now accepted: the null assistant turn coerces
# to "" and is dropped. The route must then coalesce the two remaining user turns
# so a strict GGUF chat template does not 400 on non-alternating roles.
backend = _mock_backend(monkeypatch, context_length = 2048)
class _Req:
state = SimpleNamespace()
url = SimpleNamespace(path = "/v1/messages")
method = "POST"
async def is_disconnected(self):
return False
payload = AnthropicMessagesRequest(
model = "x",
max_tokens = 16,
messages = [
{"role": "user", "content": "first question"},
{"role": "assistant", "content": None},
{"role": "user", "content": "please continue"},
],
)
response = _drive(anthropic_messages(payload, request = _Req(), current_subject = "t"))
assert response.status_code == 200
[(_path, kwargs)] = backend.calls
user_turns = [m for m in kwargs["messages"] if m.get("role") == "user"]
assert len(user_turns) == 1 # the two user turns were merged, not left adjacent
merged = user_turns[0]["content"]
if isinstance(merged, list):
merged = " ".join(p.get("text", "") for p in merged if isinstance(p, dict))
assert "first question" in merged and "please continue" in merged