Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0b1ccf8
fix(streaming): keep the provider's served service_tier on streamed c…
devin-ai-integration[bot] Sep 24, 2026
e6722a9
fix(streaming): satisfy type-discipline and strict ruff budgets
devin-ai-integration[bot] Sep 24, 2026
69fe084
fix(streaming): stamp the served service_tier on every Responses brid…
devin-ai-integration[bot] Sep 24, 2026
7039b6d
fix(anthropic-adapter): expose streamed chunks so disconnects bill pa…
kerry-berri Sep 25, 2026
909bb4c
test(service-tier): cover anthropic and responses served-tier billing…
kerry-berri Sep 25, 2026
bf86914
fix(anthropic-adapter): return a chunks-exposing stream so disconnect…
kerry-berri Sep 25, 2026
047b506
test(service-tier): bill disconnects through the router's anthropic s…
kerry-berri Sep 25, 2026
38ee5f1
Merge remote-tracking branch 'origin/main' into litellm_stream_served…
kerry-berri Sep 25, 2026
13377df
style: apply ruff format to the anthropic stream changes
kerry-berri Sep 25, 2026
2f7e0e7
test(coverage): ignore delegating properties the ast scan cannot see
kerry-berri Sep 25, 2026
8a563ef
style: keep the cast-ok reasons on the cast call line
kerry-berri Sep 25, 2026
d3b8af9
test(integration): cover served service_tier billing for streamed cha…
kerry-berri Sep 25, 2026
d6acd53
fix(anthropic-cache): delegate chunks/messages/model through the mess…
kerry-berri Sep 25, 2026
d343d3f
fix(streaming): keep service_tier on OpenAI-compatible parsed chunks
kerry-berri Sep 25, 2026
818e9e4
refactor(streaming): parameterize delegated chunks and messages types
kerry-berri Sep 25, 2026
3d909b9
Merge remote-tracking branch 'origin/main' into litellm_stream_served…
kerry-berri Sep 25, 2026
d07a7c5
Merge remote-tracking branch 'origin/main' into litellm_stream_served…
kerry-berri Sep 25, 2026
cc446c6
Merge remote-tracking branch 'origin/main' into litellm_stream_served…
kerry-berri Sep 26, 2026
9e0d6f5
Merge remote-tracking branch 'origin/main' into litellm_stream_served…
kerry-berri Sep 26, 2026
7276946
fix(tests): follow the anthropic pass_through rename after merging main
kerry-berri Sep 26, 2026
761f369
test(anthropic): drain the logging worker between response cache tests
kerry-berri Sep 26, 2026
0d8fad7
test(spend): cover azure, databricks, responses bridge and gemini ser…
kerry-berri Sep 27, 2026
fc268b1
fix(databricks): keep the served service_tier on streamed chunks and …
kerry-berri Sep 27, 2026
bc9cb9f
fix(databricks): type the served service_tier chunk without a loose k…
kerry-berri Sep 27, 2026
4db1654
fix(cost): bill the served service_tier over the requested one
kerry-berri Sep 29, 2026
aaf6c94
refactor(cost): drop explanatory comment from the tier resolution
kerry-berri Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,7 @@ def __init__(
):
super().__init__(streaming_response, sync_stream, json_mode)
self._chat_completion_id: str | None = None
self._served_service_tier: str | None = None
self._tool_call_index_map: dict[int, int] = {} # mutable-ok: per-stream accumulator state

def _handle_string_chunk(
Expand Down Expand Up @@ -1598,6 +1599,7 @@ def translate_responses_chunk_to_openai_stream(

usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(response_data.get("usage"))
provider_metadata: Final = _provider_metadata(response_data)
served_service_tier: Final = response_data.get("service_tier")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return ModelResponseStream(
choices=[
StreamingChoices(
Expand All @@ -1611,6 +1613,11 @@ def translate_responses_chunk_to_openai_stream(
],
usage=usage,
provider_specific_fields=dict(provider_metadata) or None, # mutable-ok: field is typed dict
**(
MappingProxyType({"service_tier": served_service_tier})
if isinstance(served_service_tier, str)
else MappingProxyType({})
),
)
else:
pass
Expand Down Expand Up @@ -1639,12 +1646,28 @@ def chunk_parser(self, chunk: dict[str, object]) -> "ModelResponseStream":
ModelResponseStream: OpenAI-formatted streaming chunk
"""
verbose_logger.debug("Chat provider: transform_streaming_response called with chunk: %s", chunk)
return self._with_stream_scoped_id(
OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
chunk, tool_call_index_map=self._tool_call_index_map
self._remember_served_service_tier(chunk)
return self._with_served_service_tier(
self._with_stream_scoped_id(
OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
chunk, tool_call_index_map=self._tool_call_index_map
)
)
)

def _remember_served_service_tier(self, chunk: dict[str, object]) -> None:
response_payload: Final = chunk.get("response")
if not isinstance(response_payload, dict):
return
served_tier: Final = response_payload.get("service_tier")
if isinstance(served_tier, str) and served_tier:
self._served_service_tier = served_tier

def _with_served_service_tier(self, chunk: "ModelResponseStream") -> "ModelResponseStream":
if self._served_service_tier is not None and chunk.model_dump().get("service_tier") is None:
setattr(chunk, "service_tier", self._served_service_tier) # noqa: B010 # pydantic extra, not a declared field
return chunk

def _with_stream_scoped_id(self, chunk: "ModelResponseStream") -> "ModelResponseStream":
if self._chat_completion_id is None:
self._chat_completion_id = chunk.id
Expand Down
14 changes: 14 additions & 0 deletions litellm/litellm_core_utils/streaming_chunk_builder_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class _BaseChunk(TypedDict, total=False):
created: ReadOnly[int]
model: ReadOnly[str]
system_fingerprint: ReadOnly[str | None]
service_tier: ReadOnly[str | None]
choices: ReadOnly[Required[Sequence[StreamingChoices]]]
_hidden_params: ReadOnly[_ChunkHiddenParams]

Expand Down Expand Up @@ -369,6 +370,13 @@ def _get_model_from_chunks(chunks: Sequence["_BaseChunk"], first_chunk_model: st
# Fall back to first chunk's model if no different model found
return first_chunk_model

@staticmethod
def _get_service_tier_from_chunks(chunks: Sequence["_BaseChunk"]) -> str | None:
return next(
(tier for chunk in reversed(chunks) if isinstance(tier := chunk.get("service_tier"), str) and tier),
None,
)

def build_base_response(self, chunks: Sequence["_BaseChunk"]) -> ModelResponse:
chunk = self.first_chunk
id: Final = ChunkProcessor._get_chunk_id(chunks)
Expand All @@ -378,6 +386,7 @@ def build_base_response(self, chunks: Sequence["_BaseChunk"]) -> ModelResponse:
# Get the actual model - for Azure Model Router, this finds the real model from later chunks
model: Final = ChunkProcessor._get_model_from_chunks(chunks, first_chunk_model)
system_fingerprint: Final = chunk.get("system_fingerprint", None)
service_tier: Final = ChunkProcessor._get_service_tier_from_chunks(chunks)

role: Final = ChunkProcessor._get_role_from_chunks(chunks)
finish_reason = "stop"
Expand All @@ -399,6 +408,11 @@ def build_base_response(self, chunks: Sequence["_BaseChunk"]) -> ModelResponse:
"created": created,
"model": model,
"system_fingerprint": system_fingerprint,
**(
MappingProxyType({"service_tier": service_tier})
if service_tier is not None
else MappingProxyType({})
),
"choices": [
{
"index": 0,
Expand Down
8 changes: 8 additions & 0 deletions litellm/litellm_core_utils/streaming_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ def _next_sync_or_exhausted(it: Any) -> object:
return _SYNC_ITER_EXHAUSTED


def _stamp_served_service_tier(response: ModelResponseStream, complete_streaming_response: ModelResponse) -> None:
served_tier: Final = complete_streaming_response.model_dump().get("service_tier")
if isinstance(served_tier, str) and served_tier:
setattr(response, "service_tier", served_tier) # noqa: B010 # pydantic extra, not a declared field


def is_async_iterable(obj: object) -> bool:
"""
Check if an object is an async iterable (can be used with 'async for').
Expand Down Expand Up @@ -1873,6 +1879,7 @@ def __next__(self) -> "ModelResponseStream":
"usage",
getattr(complete_streaming_response, "usage"),
)
_stamp_served_service_tier(response, complete_streaming_response)
try:
_cache_copy = complete_streaming_response.model_copy(deep=True)
_log_copy = complete_streaming_response.model_copy(deep=True)
Expand Down Expand Up @@ -2124,6 +2131,7 @@ async def _finalize_completed_stream(self, cache_hit: bool) -> "ModelResponseStr
"usage",
getattr(complete_streaming_response, "usage"),
)
_stamp_served_service_tier(response, complete_streaming_response)
try:
_copy = complete_streaming_response.model_copy(deep=True)
except RuntimeError:
Expand Down
60 changes: 60 additions & 0 deletions litellm/llms/anthropic/pass_through/adapters/streaming_iterator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Final,
Literal,
Protocol,
cast,
get_args,
)

Expand All @@ -35,6 +36,7 @@

if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponseStream


Expand Down Expand Up @@ -115,6 +117,18 @@ def __init__(self, completion_stream: _ChunkStream):
self._async_iter: AsyncIterator[ModelResponseStream] | None = None
self._buffer: deque[ModelResponseStream] = deque()

@property
def chunks(self) -> "list[ModelResponseStream] | None":
return cast( # cast-ok: chunks is a list of ModelResponseStream on the inner stream
"list[ModelResponseStream] | None", getattr(self._stream, "chunks", None)
)

@property
def messages(self) -> "list[AllMessageValues] | None":
return cast( # cast-ok: messages is a list of AllMessageValues on the inner stream
"list[AllMessageValues] | None", getattr(self._stream, "messages", None)
)

@staticmethod
def _is_combined(chunk: "ModelResponseStream") -> bool:
"""True if ``chunk`` carries response content AND a finish_reason."""
Expand Down Expand Up @@ -351,6 +365,18 @@ def __init__(
text="",
)

@property
def chunks(self) -> "list[ModelResponseStream] | None":
return cast( # cast-ok: chunks is a list of ModelResponseStream on the inner stream
"list[ModelResponseStream] | None", getattr(self.completion_stream, "chunks", None)
)

@property
def messages(self) -> "list[AllMessageValues] | None":
return cast( # cast-ok: messages is a list of AllMessageValues on the inner stream
"list[AllMessageValues] | None", getattr(self.completion_stream, "messages", None)
)

def _merge_usage_into_held_stop_reason_chunk(self, chunk: Any) -> MessageBlockDelta:
"""Merge usage data from ``chunk`` into the held ``message_delta`` chunk.

Expand Down Expand Up @@ -1173,3 +1199,37 @@ def _should_start_new_content_block(self, chunk: "ModelResponseStream") -> bool:
return True

return False


class AnthropicSSEStream(AsyncIterator[bytes]):
"""
AsyncIterator[bytes] view of AnthropicStreamWrapper returned to callers of
translate_completion_output_params_streaming. Keeps the wrapper reachable so
the proxy's disconnect-time partial billing can read the inner chat stream's
collected chunks, messages, and model; a bare async generator would hide them.
"""

def __init__(self, anthropic_wrapper: AnthropicStreamWrapper) -> None:
self._anthropic_wrapper = anthropic_wrapper
self._byte_stream: Final[AsyncIterator[bytes]] = anthropic_wrapper.async_anthropic_sse_wrapper()
self._hidden_params: dict[
str, object
] = {} # mutable-ok: the proxy merges provider headers onto _hidden_params in place

@property
def chunks(self) -> "list[ModelResponseStream] | None":
return self._anthropic_wrapper.chunks

@property
def messages(self) -> "list[AllMessageValues] | None":
return self._anthropic_wrapper.messages

@property
def model(self) -> str:
return self._anthropic_wrapper.model

async def __anext__(self) -> bytes:
return await self._byte_stream.__anext__()

async def aclose(self) -> None:
await self._byte_stream.aclose()
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def create_tool_name_mapping(
from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage
from litellm.utils import supports_mid_conversation_system

from .streaming_iterator import AnthropicStreamWrapper
from .streaming_iterator import AnthropicSSEStream, AnthropicStreamWrapper

if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
Expand Down Expand Up @@ -341,7 +341,7 @@ def translate_completion_output_params_streaming(
)
# Return the SSE-wrapped version for proper event formatting.
if is_async:
return anthropic_wrapper.async_anthropic_sse_wrapper()
return AnthropicSSEStream(anthropic_wrapper)
return anthropic_wrapper.anthropic_sse_wrapper()


Expand Down
22 changes: 21 additions & 1 deletion litellm/llms/anthropic/pass_through/messages/response_cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re
from collections.abc import AsyncIterator, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, cast

import litellm
from litellm._logging import verbose_logger
Expand All @@ -17,6 +17,8 @@
if TYPE_CHECKING:
from litellm.caching.caching_handler import LLMCachingHandler
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponseStream

CACHED_STREAM_EVENTS_KEY: Final = "litellm_cached_anthropic_sse_events"

Expand Down Expand Up @@ -51,6 +53,24 @@ def __init__(
def has_buffered_provider_output(self) -> bool:
return getattr(self.stream, "has_buffered_provider_output", False) is True

@property
def chunks(self) -> "list[ModelResponseStream] | None":
return cast( # cast-ok: chunks is a list of ModelResponseStream on the inner stream
"list[ModelResponseStream] | None", getattr(self.stream, "chunks", None)
)

@property
def messages(self) -> "list[AllMessageValues] | None":
return cast( # cast-ok: messages is a list of AllMessageValues on the inner stream
"list[AllMessageValues] | None", getattr(self.stream, "messages", None)
)

@property
def model(self) -> str | None:
return cast( # cast-ok: model is a str on the inner stream
"str | None", getattr(self.stream, "model", None)
)

def __aiter__(self) -> "AnthropicMessagesStreamCacheWriter":
return self

Expand Down
3 changes: 3 additions & 0 deletions litellm/llms/openai/chat/gpt_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,9 @@ def chunk_parser(self, chunk: dict) -> ModelResponseStream:
}
if "usage" in chunk and chunk["usage"] is not None:
kwargs["usage"] = chunk["usage"]
service_tier: Final = chunk.get("service_tier")
if isinstance(service_tier, str) and service_tier:
kwargs["service_tier"] = service_tier
return ModelResponseStream(**kwargs)
except Exception as e:
raise e
3 changes: 2 additions & 1 deletion litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9246,9 +9246,10 @@ def _fast_serialize_simple_model_response_stream(
"object": getattr(chunk, "object", None),
"created": getattr(chunk, "created", None),
"model": model,
"service_tier": getattr(chunk, "service_tier", None),
"choices": [choice_dict],
}
for top_level_key in ("id", "object", "created"):
for top_level_key in ("id", "object", "created", "service_tier"):
if payload[top_level_key] is None:
payload.pop(top_level_key)
return orjson.dumps(payload)
Expand Down
18 changes: 18 additions & 0 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,24 @@ def __init__(self, async_generator: AsyncGenerator[bytes, None], source_iterator
def has_buffered_provider_output(self) -> bool:
return getattr(self._source_iterator, "has_buffered_provider_output", False) is True

@property
def chunks(self) -> list[ModelResponseStream] | None:
return cast( # cast-ok: chunks is a list of ModelResponseStream on the inner stream
"list[ModelResponseStream] | None", getattr(self._source_iterator, "chunks", None)
)

@property
def messages(self) -> list[AllMessageValues] | None:
return cast( # cast-ok: messages is a list of AllMessageValues on the inner stream
"list[AllMessageValues] | None", getattr(self._source_iterator, "messages", None)
)

@property
def model(self) -> str | None:
return cast( # cast-ok: model is a str on the inner stream
"str | None", getattr(self._source_iterator, "model", None)
)

def adopt_fallback_source(self, fallback_response: object) -> None:
self._source_iterator = fallback_response
self.fallback_headers_adopted = True
Expand Down
3 changes: 3 additions & 0 deletions tests/code_coverage_tests/router_code_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ def get_functions_from_router(file_path):
"_merge_tools_from_deployment", # Tested indirectly via _update_kwargs_with_deployment (test files lack "router" in name)
"_invalidate_access_groups_cache", # Tested indirectly via set_model_list, upsert_model etc. (test files lack "router" in name)
"has_buffered_provider_output", # Property, so its reads in test_router.py are never an ast.Call
"chunks", # Property on FallbackAwareAnthropicMessagesStream, so its reads in tests are never an ast.Call
"messages", # Property on FallbackAwareAnthropicMessagesStream, so its reads in tests are never an ast.Call
"model", # Property on FallbackAwareAnthropicMessagesStream, so its reads in tests are never an ast.Call
"_request_header", # Tested through Claude Code session routing in test_router.py
"_claude_code_session_router_cache_key", # Tested through Claude Code session routing in test_router.py
"_delete_claude_code_session_router_binding", # Tested through Redis cleanup failure in test_router.py
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/coverage_registry/llm_conversational.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- {id: llm.chat_completions.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "gpt-4o vision; high usage"}
- {id: llm.chat_completions.openai.prompt_cache_5m.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: openai, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Prompt caching cost optimization"}
- {id: llm.chat_completions.openai.service_tier.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: nonstream, assertions: [works], source: "OpenAI service_tier param", rationale: "OpenAI scale-tier request option is forwarded and echoed"}
- {id: llm.chat_completions.openai.service_tier.stream.echoes_served_tier, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: service_tier, streaming: stream, assertions: [works], source: "litellm_core_utils/streaming_handler.py", fail_before_fix: proven, rationale: "Every relayed stream chunk carries the service_tier OpenAI stamped on it, so a streaming caller can see which tier served the request"}
- {id: llm.chat_completions.openai.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "o-series reasoning; emerging"}
- {id: llm.chat_completions.openai.structured_output.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: openai, capability: structured_output, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "response_schema extraction"}
- {id: llm.chat_completions.anthropic.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: anthropic, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "P0 route translated to Anthropic"}
Expand Down
Loading
Loading