fix(router): continue the trailing text block in place when extending a prefill with content blocks

Anthropic strips trailing whitespace per content block, so appending the partial output as a new text block after an assistant turn that ends in "Sure, " would send the prefill as "Sure,here". The continuation now extends the last text block itself and only opens a new text block when the trailing block is not text (an image, say), so the prefill reads exactly as the text the caller already received
This commit is contained in:
mateo-berri 2026-10-01 18:40:26 -07:00
parent d2ab01a88b
commit 1fc3520f7d
2 changed files with 18 additions and 8 deletions

View file

@ -478,6 +478,14 @@ _SILENT_MODEL_ADAPTER: Final = TypeAdapter(str | list[str])
_CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
def _extend_trailing_text_block(blocks: Sequence[Mapping[str, object]], text: str) -> Sequence[Mapping[str, object]]:
"""Continue the last text block in place so the prefill reads exactly as the text the caller already received."""
last: Final = blocks[-1] if blocks else None
if last is not None and last.get("type") == "text" and isinstance(last_text := last.get("text"), str):
return [*blocks[:-1], {**last, "text": f"{last_text}{text}"}]
return [*blocks, {"type": "text", "text": text}]
def _as_retry_skipped_deployment_ids(value: object) -> tuple[str, ...]:
return tuple(item for item in value if isinstance(item, str)) if isinstance(value, tuple) else ()
@ -3260,7 +3268,7 @@ class Router:
blocks: Final = _CONTENT_BLOCKS_ADAPTER.validate_python(content)
except ValidationError:
return None
extended: Final = [*blocks, {"type": "text", "text": generated_content}]
extended: Final = _extend_trailing_text_block(blocks, generated_content)
return [*messages[:-1], {**last, "content": extended, "prefix": True}]
@staticmethod

View file

@ -2902,17 +2902,19 @@ def test_build_completion_continuation_input_folds_into_trailing_plain_assistant
def test_build_completion_continuation_input_keeps_structured_assistant_content():
"""Content blocks on a trailing assistant turn stay blocks: the partial lands as one more text
block instead of a stringified list, and a content shape that cannot be extended declines."""
"""Content blocks on a trailing assistant turn stay blocks: the partial continues the last text
block in place (a block boundary would let a provider drop the space between "Sure, " and "here"),
lands as a new text block only after a non-text block, and a content shape that cannot be extended declines."""
from litellm.router import _extend_trailing_text_block
messages = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": [{"type": "text", "text": "Sure, "}]}]
built = litellm.Router._build_completion_continuation_input(messages, "here it is")
assert built is not None
assert [m["role"] for m in built] == ["user", "assistant"]
assert built[-1] == {
"role": "assistant",
"content": [{"type": "text", "text": "Sure, "}, {"type": "text", "text": "here it is"}],
"prefix": True,
}
assert built[-1] == {"role": "assistant", "content": [{"type": "text", "text": "Sure, here it is"}], "prefix": True}
image_block = {"type": "image_url", "image_url": {"url": "https://example.test/a.png"}}
assert _extend_trailing_text_block([image_block], "here it is") == [image_block, {"type": "text", "text": "here it is"}]
assert _extend_trailing_text_block([], "here it is") == [{"type": "text", "text": "here it is"}]
assert litellm.Router._build_completion_continuation_input([{"role": "assistant", "content": 42}], "x") is None