diff --git a/routstr/upstream/messages_dispatch.py b/routstr/upstream/messages_dispatch.py index 1951768d..6de5e57f 100644 --- a/routstr/upstream/messages_dispatch.py +++ b/routstr/upstream/messages_dispatch.py @@ -84,6 +84,34 @@ ALLOWED_MESSAGES_REQUEST_FIELDS: frozenset[str] = frozenset( ) +def prune_blank_system_blocks(body: dict) -> None: + """Drop whitespace-only ``system`` text. + + Anthropic accepts a blank system prompt; OpenAI-compatible upstreams + reject it with ``text content blocks must contain non-whitespace text``. + """ + system = body.get("system") + if isinstance(system, str): + if not system.strip(): + body.pop("system", None) + return + if not isinstance(system, list): + return + kept = [ + block + for block in system + if not ( + isinstance(block, dict) + and block.get("type") == "text" + and not str(block.get("text") or "").strip() + ) + ] + if kept: + body["system"] = kept + else: + body.pop("system", None) + + def coerce_litellm_payload(payload: object) -> dict: """Convert a litellm event into a plain dict. @@ -511,6 +539,8 @@ async def dispatch_anthropic_messages( ) body = {k: v for k, v in body.items() if k in ALLOWED_MESSAGES_REQUEST_FIELDS} + prune_blank_system_blocks(body) + model_suffix = adapt_request(body) if adapt_request else "" # LiteLLM turns Anthropic's server-side web_search tool into the OpenAI diff --git a/tests/unit/test_messages_litellm_dispatch.py b/tests/unit/test_messages_litellm_dispatch.py index 7e2da476..47b3ccd9 100644 --- a/tests/unit/test_messages_litellm_dispatch.py +++ b/tests/unit/test_messages_litellm_dispatch.py @@ -23,6 +23,9 @@ from routstr.core.db import ApiKey # noqa: E402 from routstr.payment.cost_calculation import CostData # noqa: E402 from routstr.payment.models import Architecture, Model, Pricing # noqa: E402 from routstr.upstream.base import BaseUpstreamProvider # noqa: E402 +from routstr.upstream.messages_dispatch import ( # noqa: E402 + prune_blank_system_blocks, +) from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402 # --------------------------------------------------------------------------- @@ -113,6 +116,39 @@ def _make_request(request_id: str | None = "req-test") -> Any: # --------------------------------------------------------------------------- +def test_prune_blank_system_blocks_drops_blank_blocks() -> None: + body = { + "system": [ + {"type": "text", "text": " \n"}, + {"type": "text", "text": "real prompt"}, + ] + } + prune_blank_system_blocks(body) + assert body["system"] == [{"type": "text", "text": "real prompt"}] + + +def test_prune_blank_system_blocks_drops_key_when_all_blank() -> None: + body = {"system": [{"type": "text", "text": "\n"}], "max_tokens": 8} + prune_blank_system_blocks(body) + assert body == {"max_tokens": 8} + + +def test_prune_blank_system_blocks_handles_string_system() -> None: + blank = {"system": " "} + prune_blank_system_blocks(blank) + assert blank == {} + + kept = {"system": "be brief"} + prune_blank_system_blocks(kept) + assert kept == {"system": "be brief"} + + +def test_prune_blank_system_blocks_keeps_non_text_blocks() -> None: + body = {"system": [{"type": "image", "source": {}}]} + prune_blank_system_blocks(body) + assert body["system"] == [{"type": "image", "source": {}}] + + def test_coerce_litellm_payload_handles_dict() -> None: out = BaseUpstreamProvider._coerce_litellm_payload({"a": 1}) assert out == {"a": 1}