litellm/tests/unit/test_responses_streaming_container_ownership.py
devin-ai-integration[bot] fbc2e358bc
fix(responses): bill a background response once, on the first read or poll that sees it finished (#45703)
* fix(responses): bill a background response once, from the poll job

Reading a completed background response with GET /v1/responses/{id} wrote a
full-cost spend row on every read, on top of the row the background cost poll
writes. Reads of a Responses object (GET, DELETE, cancel) now never bill, and
the poll job is the single biller.

The poll job now bills with the creating request's attribution (key hash, team,
org, user, aliases, tags) read off the managed object row, through a helper it
shares with the batch cost poll. The background create persists the creating
key and request tags so that attribution survives until the poll runs.

* test(proxy): a background response read reports zero cost

* fix(responses): bill a background response once, on the first read or poll that sees it finished

A conditional update moves the stored row out of queued or in_progress so only one worker or pod bills it, through one re-read stamped with the creator's attribution. A failed billing read puts the row back for the next poll

* fix(responses): keep the caller's response id out of the billing failure warning

* fix(responses): keep the creating client's User-Agent tags on the one background charge

* fix(responses): keep managed background reads free and bill each response once

The managed files hook marks reads and cancels of proxy-issued response ids, so only those stay free. SDK and database-less reads of a background response still bill, extra_body background creates are tracked, and the poll's status read no longer cools down a deployment on a 404.

* fix(responses): bill finished background responses from the fetched body and store rows Postgres accepts

* fix(responses): give the background billing event its own spend log row and bill responses the create left untracked

* fix(responses): narrow the managed files hook with a protocol instead of an enterprise import

* fix(responses): leave a read the caller retargets with client-side credentials to the cost poll

* fix(responses): bill background responses on the creator's end user and report the charge on the settling read

The read that settles the bill returns the charge in x-litellm-response-cost, the billed event
carries the creator's end user and the key, team, and user budget fields, and its spend row is
named after the id the client got. Override reads, cancels, and deletes settle from the
configured deployment, a streamed create marks its row billed, stored rows keep no model
output, and model insights counts the billed row's spend without a request.

* test(responses): cover background response billing across proxies, reads and chaos

* fix(responses): settle background billing only for callers allowed to address the response

A delete now settles the bill only after the response id access check passes, a streamed
create is marked billed when it sets background inside extra_body or its completed event
arrives after the last chunk, the billing event keeps the deployment's litellm_params
base_model, a key with no user and no team bills no user instead of its owner scope, and the
enterprise cost check import is optional

* fix(responses): record the bound on the pending status IN filters

* fix(responses): send the billed cost split on the settling read and keep the batch cost event as it was

The read that settles a background response now sends the whole cost header
family from the billed event, so the breakdown headers add up to the charge.
Batch cost events go back to main's attribution (creator id as the user, no
budget fields); only background responses get the end user and budgets.

Integration tests expect the charge on the settling read (or none when the
poll billed first), one charge for a streamed create, and SDK flows that poll
with the id each read returns.

* fix(responses): bill each background response once across upgrades, streams, and failed settles

Rows stored before this billing model are found by the issued id and rekeyed by the
provider id, and the cost poll retires one a read already billed. A streamed background
create is free on its own and bills the finished response through the same managed row
before the client sees response.completed, so a racing read cannot bill it again. A
settle that fails retries in the background, a retargeted read with no row tracks and
bills from the configured deployment, and a delete keeps its billed cost header.
Reverts the unrelated json.dumps narrowing in the completion transformation.

* fix(responses): bill cache-polled background streams and match poll rows by the issued id

* test(responses): cover deletes, failed settles, budgets, base models, and cache-polled background billing

Adds integration cells for the background response billing paths the audit
inventory lists: a DELETE that settles the bill and refuses a stranger, a
settle the database refuses that retries once it recovers, the Prometheus
remaining budget, base_model pricing from model_info and litellm_params, the
daily model usage rollup, the end user, the settling read's cost split, stored
rows without model output, a forged managed marker on the create, streamed
creates with background in extra_body, and the cache-polled create

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-15 11:00:14 -07:00

261 lines
9 KiB
Python

"""
Regression for #30210.
When streaming /v1/responses goes through the proxy + Router, the
streaming iterator is wrapped by ``Router._aresponses_streaming_iterator``
which returns ``FallbackResponsesStreamWrapper``. That wrapper set
``self.completed_response = None`` in __init__ and never updated it,
so the proxy's container-ownership hook (which reads
``getattr(stream_response, "completed_response", None)`` via
``ProxyBaseLLMRequestProcessing.extract_completed_responses_response``)
saw None on every streaming call and silently recorded nothing —
follow-up ``GET /v1/containers/<id>/files`` then 403'd for the very
key that created the container.
Tests below construct the wrapper from a fake async generator that
yields one terminal ``response.completed`` chunk and assert the
wrapper now carries that chunk on ``completed_response`` so the
proxy hook can walk it.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _make_wrapper_class():
"""Pull ``FallbackResponsesStreamWrapper`` out by running
``Router._aresponses_streaming_iterator`` long enough to construct
the class then return it. Mirrors how the wrapper is actually
instantiated in production."""
from litellm.router import Router
from litellm.responses.streaming_iterator import (
BaseResponsesAPIStreamingIterator,
)
# Minimal source iterator stub with every attribute the wrapper
# copies in __init__ (see router.py:2552-2583).
source = SimpleNamespace(
response=None,
model="openai/gpt-5.5",
logging_obj=None,
responses_api_provider_config=None,
start_time=None,
litellm_metadata=None,
custom_llm_provider="openai",
request_data={},
call_type="aresponses",
_hidden_params={},
)
# The class is defined inside _aresponses_streaming_iterator; capture
# it by patching FallbackResponsesStreamWrapper into a sentinel on
# construction.
captured = {}
real_router_module = __import__("litellm.router", fromlist=["Router"])
async def _drive():
async def empty_gen():
if False:
yield # pragma: no cover
return
router = Router(
model_list=[
{
"model_name": "openai/gpt-5.5",
"litellm_params": {"model": "openai/gpt-5.5", "api_key": "sk-test"},
}
]
)
wrapped = await router._aresponses_streaming_iterator(
response=source, # type: ignore[arg-type]
initial_kwargs={},
)
captured["wrapper_cls"] = type(wrapped)
captured["instance"] = wrapped
asyncio.run(_drive())
return captured["wrapper_cls"], captured["instance"]
def _terminal_chunk(event_type: str):
"""A SimpleNamespace shaped like the openai responses-api terminal
event chunks the wrapper inspects (.type attribute)."""
return SimpleNamespace(
type=event_type,
response=SimpleNamespace(
id="resp_test",
output=[],
container={"id": "cntr_test", "type": "code_interpreter"},
),
)
def _non_terminal_chunk(event_type: str = "response.output_text.delta"):
return SimpleNamespace(type=event_type, delta="hello")
class TestStreamWrapperCapturesTerminalEvent:
def test_terminal_completed_event_is_recorded_on_wrapper(self):
"""The #30210 bug: a forwarded ``response.completed`` chunk used
to leave ``completed_response`` at None on the wrapper. Verify
it now carries the chunk."""
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _non_terminal_chunk()
yield _terminal_chunk("response.completed")
wrapper = wrapper_cls(gen())
# Drain the wrapper.
out = asyncio.run(_drain(wrapper))
assert len(out) == 2
assert wrapper.completed_response is not None, (
"FallbackResponsesStreamWrapper.completed_response is still None "
"after a response.completed chunk passed through — the proxy "
"container-ownership hook will 403 follow-up file lookups (#30210)"
)
assert wrapper.completed_response.type == "response.completed"
def test_terminal_incomplete_event_is_recorded(self):
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _terminal_chunk("response.incomplete")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert wrapper.completed_response is not None
assert wrapper.completed_response.type == "response.incomplete"
def test_terminal_failed_event_is_recorded(self):
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _terminal_chunk("response.failed")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert wrapper.completed_response is not None
assert wrapper.completed_response.type == "response.failed"
def test_non_terminal_chunks_do_not_set_completed_response(self):
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _non_terminal_chunk("response.output_text.delta")
yield _non_terminal_chunk("response.code_interpreter.in_progress")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert (
wrapper.completed_response is None
), "non-terminal chunks must not set completed_response"
def test_first_terminal_event_wins(self):
"""Real streams only emit one terminal event, but defend against
future producers emitting more: keep the first one (the inner
source iterator behaves the same way)."""
wrapper_cls, _ = _make_wrapper_class()
first = _terminal_chunk("response.completed")
first.response.id = "resp_first"
second = _terminal_chunk("response.completed")
second.response.id = "resp_second"
async def gen():
yield first
yield second
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
assert wrapper.completed_response.response.id == "resp_first"
class TestProxyOwnershipHookReadsCompletedResponse:
"""End-to-end: the proxy hook reads exactly the attribute the
wrapper now populates. Pin that the helper still extracts the
response correctly so the ownership recording path doesn't break."""
def test_extract_returns_inner_response_object(self):
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _terminal_chunk("response.completed")
wrapper = wrapper_cls(gen())
asyncio.run(_drain(wrapper))
extracted = ProxyBaseLLMRequestProcessing.extract_completed_responses_response(
wrapper
)
assert extracted is not None
assert extracted.id == "resp_test"
assert extracted.container["id"] == "cntr_test"
class TestSilentSkipNowLogged:
"""Reporter's secondary ask: when completed_response is None, the
ownership hook silently dropped on the floor. Make sure the new
warning fires so operators see a hint instead of a mute 403."""
def test_warning_logged_when_completed_response_missing(self, caplog):
import logging
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
# Wrap a generator that produces NO terminal event so the
# wrapper stays at completed_response=None — same shape as the
# pre-fix bug.
wrapper_cls, _ = _make_wrapper_class()
async def gen():
yield _non_terminal_chunk()
wrapper = wrapper_cls(gen())
async def driver():
async def inner_gen():
async for c in wrapper:
yield c
# Patch _record_container_owners_from_responses_if_needed to
# a noop async so the warning branch is exercised in
# isolation.
with patch.object(
ProxyBaseLLMRequestProcessing,
"_record_container_owners_from_responses_if_needed",
new=MagicMock(),
):
wrapped = ProxyBaseLLMRequestProcessing._wrap_responses_stream_for_container_ownership(
original_stream_response=wrapper,
wrapped_generator=inner_gen(),
user_api_key_dict=MagicMock(),
)
async for _ in wrapped:
pass
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
asyncio.run(driver())
assert any(
"Container ownership recording skipped on streaming /v1/responses"
in r.message
for r in caplog.records
), "silent-skip warning never fired despite completed_response=None"
async def _drain(it):
out = []
async for chunk in it:
out.append(chunk)
return out