litellm/tests/integration/streaming/test_decision_model_buffering.py
yucheng-berri efc2304b7e
fix(streaming)!: fail a stream that ends without a finish reason (#47665)
* fix(streaming): fail on clean EOF without finish reason

* fix(streaming): cover shared OpenAI-compatible EOF paths

* test(router): complete mocked streaming responses

* fix(streaming): keep the EOF error message and cover Anthropic clean EOF

The request-less RemoteProtocolError tripped exception mapping into
'The .request property has not been set.' for non-OpenAI providers.
Adds Anthropic and generic-chunk unit tests, an integration test across
chat, the /v1/messages bridge and the /v1/responses bridge, and moves
main's token-estimate test onto a finished stream with a sibling that
checks the cut stream still bills the estimate on its failure log.

* fix(streaming): count a finish reason any chunk sent, keep LangGraph async streams whole

Gemini marks every chunk unfinished and a later usage chunk reset
intermittent_finish_reason, so a finished Gemini stream failed at EOF.
LangGraph's __aiter__ opened a new aiter_lines on every wrapper call,
which hit StreamConsumed after the first chunk and now surfaced as a
false EOF error instead of a silent truncation.

* fix(streaming): keep the synthetic stop for a stream that sent no chunks

Gemini's OpenAI-compatible endpoint answers with only [DONE] when
reasoning uses up max_tokens. Failing every openai/azure stream that
sent nothing turned that 200 into a 500, while every other provider
already kept the stop for an empty stream. Adds integration cells for
Gemini's trailing usage chunk, multi-event LangGraph runs, a mocked
deployment and the [DONE]-only answer.

* test(streaming): keep pytest.raises to one statement, drop the spend read for an empty stream

An empty stream writes no spend row on main either, so the [DONE]-only
cell checks only what the client sees.

* fix(streaming): read the chunk's finish reason for the sticky flag

* fix(agentcore): end a stream on messageStop when the runtime sends no metadata or final message

* test(streaming): pin the logged-chunks guard, inject the LangGraph client, use current model names

* fix(agentcore): say why the end-of-stream flags rebind

* test(streaming): cover an AgentCore runtime that ends on messageStop through the proxy

* test(streaming): cover SDK clients, request fallbacks and a concurrent burst for a cut stream

* test(agentcore): inject the async transport instead of patching the aiohttp flag

* test(guardrails): end the decision model chat fixture with a finish reason, as a real stream does

* test(streaming): shape the AgentCore and LangGraph fixtures like captures from a real runtime and server

* test(streaming): keep each stream's ending and land every spend row once through a spend log outage

* test(streaming): end each stream by its provider's finish after a proxy worker is killed mid burst

* test(streaming): run the outage test as one burst before and one during the outage instead of a capped wave loop

* test(streaming): connect the outage test's proxy to the relay without TLS so its trigger sees the spend-log insert on an ssl=on Postgres

* test(streaming): type the new stream EOF unit tests

* test(streaming): a merged reasoning stream cut mid reasoning now fails

Main's think tag test expected a stream that ends mid reasoning without a finish reason to finish with a closed think block. With this PR that stream fails, so the case moves to its own test that expects MidStreamFallbackError with the open think block as the partial content

---------

Co-authored-by: King Star <mcxin.y@gmail.com>
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-31 11:29:26 -07:00

245 lines
10 KiB
Python

import json
import uuid
from pathlib import Path
from typing import Final, Literal
import pytest
import yaml
from integration._support.client import JSON_OBJECT, Gateway, list_value, object_value, string_value
from integration._support.process import owned_proxy
from integration._support.upstream import delete_scenario, register_scenario
from integration.cost_calculation.cost_tracking_case import JsonResponse, SseResponse
from pydantic import JsonValue
@pytest.mark.parametrize("endpoint", ["chat/completions", "messages", "responses"])
@pytest.mark.parametrize(
("buffering", "probability", "judge_status", "fallback"),
[
(True, 1.0, 200, "fail_closed"),
(True, 0.0, 200, "fail_closed"),
(False, 1.0, 200, "fail_closed"),
(None, 1.0, 200, "fail_closed"),
(True, 0.0, 500, "fail_closed"),
(True, 0.0, 500, "fail_open"),
],
ids=["buffered-block", "buffered-allow", "unbuffered-block", "default-block", "outage-closed", "outage-open"],
)
def test_decision_model_streaming_buffering(
gateway: Gateway,
tmp_path: Path,
buffering: bool | None,
probability: float,
endpoint: str,
judge_status: int,
fallback: Literal["fail_closed", "fail_open"],
) -> None:
identity: Final = uuid.uuid4().hex
frames: Final = _frames(identity, endpoint)
chat: Final = register_scenario(
identity,
SseResponse(content_type="text/event-stream", frames=frames),
control_url=gateway.upstream_url,
)
try:
decider: Final = register_scenario(
identity + "-decider",
JsonResponse(
content_type="application/json",
status=judge_status,
body={
"model": "scripted-decider",
"answers": [{"type": "predicate", "name": "bananas", "probability": probability}],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
},
),
control_url=gateway.upstream_url,
)
try:
config: Final = {
**JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())),
"model_list": [
{
"model_name": "buffering-chat",
"litellm_params": {
"model": "anthropic/scripted-chat" if endpoint == "messages" else "openai/scripted-chat",
"api_key": "synthetic",
"api_base": chat.api_base(),
},
},
{
"model_name": "buffering-decider",
"litellm_params": {
"model": "openai/scripted-decider",
"api_key": "synthetic",
"api_base": decider.api_base(),
},
},
],
"guardrails": [
{
"guardrail_name": "banana-policy",
"litellm_params": {
"guardrail": "decision_model",
"mode": "post_call",
"default_on": True,
"decision_model": "buffering-decider",
"unreachable_fallback": fallback,
**({"streaming_buffer_until_moderated": buffering} if buffering is not None else {}),
"checks": [
{
"name": "bananas",
"instructions": "Does the text mention bananas?",
"action": "block",
"threshold": 0.5,
}
],
},
}
],
"general_settings": {"master_key": gateway.key},
}
config_path: Final = tmp_path / "decision-buffering.yaml"
config_path.write_text(yaml.safe_dump(config))
with owned_proxy(gateway, tmp_path, {}, config=config_path, workers=2) as candidate:
response: Final = candidate.request(
"POST",
f"/v1/{endpoint}",
{
"model": "buffering-chat",
**(
{"input": "Tell me about bananas"}
if endpoint == "responses"
else {"messages": [{"role": "user", "content": "Tell me about bananas"}]}
),
**({"max_tokens": 100} if endpoint == "messages" else {}),
"stream": True,
"cache": {"no-cache": True, "no-store": True},
},
)
events: Final = tuple(
JSON_OBJECT.validate_json(line.removeprefix("data: "))
for line in response.text.splitlines()
if line.startswith("data: {")
)
content: Final = "".join(_event_text(event, endpoint) for event in events)
if judge_status != 200 and fallback == "fail_closed":
assert "Decision model guardrail failed to respond" in response.text, response.text
assert content == "", response.text
elif probability == 0:
assert content == "Bananas are fruit.", response.text
assert '"error":' not in response.text, response.text
else:
assert "Violated decision model guardrail policy" in response.text, response.text
if buffering:
assert content == "", response.text
else:
assert content and "Bananas are fruit.".startswith(content), response.text
finally:
delete_scenario(decider)
finally:
delete_scenario(chat)
def _event_text(event: dict[str, JsonValue], endpoint: str) -> str:
if endpoint == "responses":
return string_value(event.get("delta", "")) if event.get("type") == "response.output_text.delta" else ""
if endpoint == "messages":
return string_value(object_value(event.get("delta", {})).get("text", ""))
choices: Final = list_value(event.get("choices", [{}]))
return string_value(object_value(object_value(choices[0]).get("delta", {})).get("content", ""))
def _frames(identity: str, endpoint: str) -> tuple[str, ...]:
pieces: Final = ("Ban", "anas", " are", " fruit", ".")
if endpoint == "messages":
events: Final = (
{
"type": "message_start",
"message": {
"id": identity,
"type": "message",
"role": "assistant",
"content": [],
"model": "scripted-chat",
"stop_reason": None,
"usage": {"input_tokens": 1},
},
},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
*(
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}}
for text in pieces
),
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 5}},
{"type": "message_stop"},
)
return tuple(f"event: {event['type']}\ndata: {json.dumps(event)}" for event in events)
if endpoint == "responses":
message: Final = {
"id": "msg_" + identity,
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "".join(pieces), "annotations": []}],
}
response: Final = {
"id": "resp_" + identity,
"object": "response",
"created_at": 1,
"status": "completed",
"model": "scripted-chat",
"output": [message],
"usage": {"input_tokens": 1, "output_tokens": 5, "total_tokens": 6},
}
response_events: Final = (
{
"type": "response.created",
"sequence_number": 0,
"response": {**response, "status": "in_progress", "output": []},
},
{
"type": "response.output_item.added",
"sequence_number": 1,
"output_index": 0,
"item": {**message, "content": []},
},
*(
{
"type": "response.output_text.delta",
"sequence_number": index + 2,
"item_id": message["id"],
"output_index": 0,
"content_index": 0,
"delta": text,
}
for index, text in enumerate(pieces)
),
{"type": "response.output_item.done", "sequence_number": 7, "output_index": 0, "item": message},
{"type": "response.completed", "sequence_number": 8, "response": response},
)
return tuple(f"event: {event['type']}\ndata: {json.dumps(event)}" for event in response_events)
return (
*(
f"data: {
json.dumps(
{
'id': identity,
'object': 'chat.completion.chunk',
'created': 1,
'model': 'scripted-chat',
'choices': [
{
'index': 0,
'delta': {'content': text},
'finish_reason': 'stop' if index == len(pieces) - 1 else None,
}
],
}
)
}"
for index, text in enumerate(pieces)
),
"data: [DONE]",
)