fix: normalize system role messages for Anthropic partner endpoints

Anthropic's native Messages API now accepts role: "system" inside the
messages array, but partner endpoints (Vertex AI, Bedrock, Azure AI
Foundry) still reject it with:

  "Unexpected role \"system\". The Messages API accepts a top-level
   `system` parameter, not \"system\" as an input message role."

Add should_normalize_system_role_messages() opt-in flag to the shared
AnthropicMessagesConfig base class. When enabled, role: "system" entries
are stripped from the messages array and merged into the top-level
system field before the request is built.

Enabled for:
- Vertex AI (vertex_ai/claude-*)
- Azure AI Foundry (azure_ai/claude-*)

Refs #29698
This commit is contained in:
Daniel Cherubini 2026-06-26 15:20:59 +02:00
parent 5b93ba0ada
commit 4b1e39fa3a
3 changed files with 83 additions and 0 deletions

View file

@ -87,6 +87,74 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if isinstance(content, list):
_process_content_list(content)
def should_normalize_system_role_messages(self) -> bool:
"""
Whether to normalize ``role: "system"`` entries from the messages array
into the top-level ``system`` field.
Anthropic's native Messages API accepts ``role: "system"`` inside messages,
but some partner endpoints (Vertex AI, Bedrock) reject it. Providers that
need this normalization override this to True.
"""
return False
@staticmethod
def _as_system_content_blocks(value: Any) -> list:
"""Convert system content to a list of content blocks."""
if value is None:
return []
if isinstance(value, list):
return list(value)
if isinstance(value, str):
return [{"type": "text", "text": value}]
return [value]
def _normalize_system_role_messages(
self,
messages: List[Dict],
anthropic_messages_optional_request_params: Dict,
) -> Tuple[List[Dict], Dict]:
"""Move ``role: "system"`` entries from the messages array into the
top-level ``system`` field.
Some Anthropic partner endpoints (Vertex AI, Bedrock) reject
``role: "system"`` inside messages — they only accept ``system`` as a
top-level parameter.
"""
system_role_messages = [
m for m in messages if isinstance(m, dict) and m.get("role") == "system"
]
if not system_role_messages:
return messages, anthropic_messages_optional_request_params
# Remove system-role entries from messages
filtered_messages = [
m
for m in messages
if not (isinstance(m, dict) and m.get("role") == "system")
]
# Merge into top-level system
existing_system = anthropic_messages_optional_request_params.get("system")
system_blocks: list = []
if existing_system is not None:
system_blocks.extend(
AnthropicMessagesConfig._as_system_content_blocks(existing_system)
)
for msg in system_role_messages:
system_blocks.extend(
AnthropicMessagesConfig._as_system_content_blocks(msg.get("content"))
)
if system_blocks:
anthropic_messages_optional_request_params["system"] = system_blocks
else:
anthropic_messages_optional_request_params.pop("system", None)
return filtered_messages, anthropic_messages_optional_request_params
def should_strip_billing_metadata(self) -> bool:
"""
Whether to drop x-anthropic-billing-header system blocks before sending upstream.
@ -284,6 +352,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
optional_params=anthropic_messages_optional_request_params,
)
# Normalize role: "system" entries from messages array into top-level system.
# Some partner endpoints (Vertex AI, Bedrock) reject role: "system" in messages.
if self.should_normalize_system_role_messages():
messages, anthropic_messages_optional_request_params = (
self._normalize_system_role_messages(
messages, anthropic_messages_optional_request_params
)
)
system_param = anthropic_messages_optional_request_params.get("system")
if self.should_strip_billing_metadata() and system_param is not None:
filtered_system = self._filter_billing_headers_from_system(system_param)

View file

@ -21,6 +21,9 @@ class AzureAnthropicMessagesConfig(AnthropicMessagesConfig):
and Azure endpoint format.
"""
def should_normalize_system_role_messages(self) -> bool:
return True
def should_strip_billing_metadata(self) -> bool:
return True

View file

@ -17,6 +17,9 @@ from ..output_params_utils import sanitize_vertex_anthropic_output_params
class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, VertexBase):
def should_normalize_system_role_messages(self) -> bool:
return True
def should_strip_billing_metadata(self) -> bool:
return True