mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
fix(passthrough): map sync streaming errors, keep router streaming responses unwrapped, and resolve gigachat from api base
- sync llm_passthrough_route: read and close an error-status streaming response before mapping it, so upstream 4xx/5xx surface as the provider error instead of httpx.ResponseNotRead - AsyncPassthroughStreamingResponse: expose aiter_bytes() and carry _hidden_params so the router attaches headers in place instead of wrapping the stream in HiddenParamsAsyncIteratorWrapper, which 500'd every streaming azure router-model passthrough request - logging: swap the passthrough httpx result for the transformed ModelResponse/EmbeddingResponse when firing success callbacks - get_llm_provider: resolve gigachat from its api base and drop the dead gigachat_models elif branch - constants: register the gigachat api base in openai_compatible_endpoints
This commit is contained in:
parent
de1f38820a
commit
ed5ee51dd2
8 changed files with 256 additions and 4 deletions
|
|
@ -806,6 +806,7 @@ openai_compatible_endpoints: Final[list] = [
|
|||
"https://api.meta.ai/v1",
|
||||
"https://api.cognition.ai/v1",
|
||||
"https://api.scx.ai/v1",
|
||||
"https://gigachat.devices.sberbank.ru/api/v1",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -496,8 +496,6 @@ def get_llm_provider(
|
|||
custom_llm_provider = "amazon_nova"
|
||||
elif model.startswith("sap/"):
|
||||
custom_llm_provider = "sap"
|
||||
elif model in litellm.gigachat_models or model.startswith("gigachat/"):
|
||||
custom_llm_provider = "gigachat"
|
||||
|
||||
# Last resort for an otherwise-unknown model: a declarative
|
||||
# fallback-generalization routing rule (e.g. routes future claude-* to anthropic).
|
||||
|
|
|
|||
|
|
@ -2141,7 +2141,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
|
||||
logging_result: Final = self.normalize_logging_result(result=result)
|
||||
|
||||
if isinstance(result, Response) and isinstance(logging_result, ModelResponse):
|
||||
if isinstance(result, Response) and isinstance(logging_result, (ModelResponse, EmbeddingResponse)):
|
||||
result = logging_result
|
||||
|
||||
if standard_logging_object is None and result is not None and self.stream is not True:
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class AsyncPassthroughStreamingResponse(AsyncGenerator[Any, Any]):
|
|||
self._raw_bytes: list[bytes] = [] # mutable-ok: instance buffer for streaming chunks
|
||||
self._flush_scheduled = False
|
||||
self._background_tasks: set[asyncio.Task] = set() # mutable-ok: instance set for background task tracking
|
||||
self._hidden_params: dict[str, object] = {} # mutable-ok: router attaches response headers here in place
|
||||
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
|
|
@ -127,6 +128,9 @@ class AsyncPassthroughStreamingResponse(AsyncGenerator[Any, Any]):
|
|||
def __aiter__(self) -> AsyncPassthroughStreamingResponse:
|
||||
return self
|
||||
|
||||
def aiter_bytes(self) -> AsyncPassthroughStreamingResponse:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> bytes:
|
||||
if not self._initialized:
|
||||
await self # pyright: ignore[reportGeneralTypeIssues] # structural type check misses __await__
|
||||
|
|
@ -556,7 +560,18 @@ def llm_passthrough_route(
|
|||
else:
|
||||
# Sync path - client.client.send returns Response directly
|
||||
response: httpx.Response = client.client.send(request=request, stream=is_streaming_request)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except Exception: # noqa: BLE001 # Safe catch-all for cleanup logic
|
||||
try:
|
||||
response.read()
|
||||
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
|
||||
pass
|
||||
try:
|
||||
response.close()
|
||||
except Exception: # noqa: BLE001 S110 # Safe catch-all for cleanup logic
|
||||
pass
|
||||
raise
|
||||
|
||||
if hasattr(response, "iter_bytes") and is_streaming_request:
|
||||
return PassthroughStreamingResponse(response, litellm_logging_obj, provider_config)
|
||||
|
|
|
|||
|
|
@ -184,3 +184,26 @@ class TestTogetherApiBaseResolvesProvider:
|
|||
|
||||
assert provider == "together_ai"
|
||||
assert api_base == "https://api.together.ai/v1"
|
||||
|
||||
|
||||
class TestGigachatApiBaseResolvesProvider:
|
||||
"""
|
||||
Regression for the GigaChat api_base branch: the provider-mapping chain
|
||||
carried an ``endpoint == "https://gigachat.devices.sberbank.ru/api/v1"``
|
||||
elif, but the URL was never added to ``openai_compatible_endpoints``, so
|
||||
the endpoint loop never fired the branch and a caller-supplied GigaChat
|
||||
api_base raised BadRequestError instead of resolving to ``gigachat``.
|
||||
"""
|
||||
|
||||
def test_gigachat_api_base_resolves_to_gigachat(self, monkeypatch):
|
||||
monkeypatch.setenv("GIGACHAT_API_KEY", "gigachat-key-from-env")
|
||||
|
||||
model, provider, dynamic_api_key, returned_api_base = get_llm_provider(
|
||||
model="GigaChat-2",
|
||||
api_base="https://gigachat.devices.sberbank.ru/api/v1",
|
||||
)
|
||||
|
||||
assert provider == "gigachat"
|
||||
assert dynamic_api_key == "gigachat-key-from-env"
|
||||
assert returned_api_base == "https://gigachat.devices.sberbank.ru/api/v1"
|
||||
assert model == "GigaChat-2"
|
||||
|
|
|
|||
|
|
@ -6101,3 +6101,64 @@ def test_response_timing_metrics_survive_deepcopy(logging_obj):
|
|||
logging_obj.set_response_timing_metrics({"_response_ms": 12.5})
|
||||
|
||||
assert copy.deepcopy(logging_obj).response_timing_metrics == {"_response_ms": 12.5}
|
||||
|
||||
|
||||
def test_passthrough_embeddings_result_swapped_for_callbacks():
|
||||
"""
|
||||
Regression: for gigachat passthrough /embeddings, normalize_logging_result
|
||||
produces an EmbeddingResponse, but the result swap only accepted
|
||||
ModelResponse, so callbacks kept receiving the raw httpx.Response (which
|
||||
crashes attribute readers like OTEL). The swap must cover
|
||||
EmbeddingResponse too.
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
|
||||
logging_obj = LitellmLogging(
|
||||
model="EmbeddingsGigaR",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="allm_passthrough_route",
|
||||
start_time=time.time(),
|
||||
litellm_call_id="passthrough-embed-call-id",
|
||||
function_id="passthrough-embed-fn-id",
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={},
|
||||
optional_params={},
|
||||
model="EmbeddingsGigaR",
|
||||
custom_llm_provider="gigachat",
|
||||
endpoint="/embeddings",
|
||||
request_data={"model": "EmbeddingsGigaR", "input": ["hello"]},
|
||||
input=["hello"],
|
||||
)
|
||||
|
||||
httpx_response = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"object": "embedding",
|
||||
"embedding": [0.1, 0.2, 0.3],
|
||||
"index": 0,
|
||||
"usage": {"prompt_tokens": 5},
|
||||
}
|
||||
],
|
||||
"model": "EmbeddingsGigaR",
|
||||
},
|
||||
request=httpx.Request(
|
||||
"POST", "https://gigachat.devices.sberbank.ru/api/v1/embeddings"
|
||||
),
|
||||
)
|
||||
|
||||
_, _, swapped_result = logging_obj._success_handler_helper_fn(
|
||||
result=httpx_response,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
cache_hit=False,
|
||||
)
|
||||
|
||||
assert isinstance(swapped_result, EmbeddingResponse)
|
||||
assert swapped_result.data[0]["embedding"] == [0.1, 0.2, 0.3]
|
||||
|
|
|
|||
|
|
@ -719,6 +719,87 @@ async def test_allm_passthrough_route_429_streaming_raises():
|
|||
assert exc_info.value.response.status_code == 429
|
||||
|
||||
|
||||
def test_llm_passthrough_route_sync_streaming_error_maps_upstream_status():
|
||||
"""
|
||||
Regression test: a sync streaming passthrough whose upstream answers an
|
||||
error status must surface the mapped provider error, not
|
||||
httpx.ResponseNotRead.
|
||||
|
||||
Before the fix, raise_for_status() raised on the still-unread streamed
|
||||
response, and _handle_error then touched e.response.text, which raises
|
||||
ResponseNotRead on a streamed-but-unread body, masking the real upstream
|
||||
error entirely.
|
||||
"""
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
error_body = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"code": "429",
|
||||
"message": "Rate limit exceeded. Retry after 10 seconds.",
|
||||
}
|
||||
}
|
||||
).encode()
|
||||
|
||||
class _UnreadErrorStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield error_body
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
429,
|
||||
stream=_UnreadErrorStream(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
sync_client = HTTPHandler(
|
||||
client=httpx.Client(transport=httpx.MockTransport(_handler))
|
||||
)
|
||||
|
||||
mock_provider_config = MagicMock()
|
||||
mock_provider_config.get_complete_url.return_value = (
|
||||
httpx.URL("https://gigachat.devices.sberbank.ru/api/v1/chat/completions"),
|
||||
"https://gigachat.devices.sberbank.ru/api/v1",
|
||||
)
|
||||
mock_provider_config.get_api_key.return_value = "fake-key"
|
||||
mock_provider_config.validate_environment.return_value = {
|
||||
"Authorization": "Bearer fake-key"
|
||||
}
|
||||
mock_provider_config.sign_request.return_value = (
|
||||
{"Authorization": "Bearer fake-key"},
|
||||
None,
|
||||
)
|
||||
mock_provider_config.is_streaming_request.return_value = True
|
||||
mock_provider_config.get_error_class.side_effect = (
|
||||
lambda error_message, status_code, headers: BaseLLMException(
|
||||
status_code=status_code, message=error_message, headers=headers
|
||||
)
|
||||
)
|
||||
|
||||
mock_logging_obj = MagicMock()
|
||||
|
||||
with pytest.raises(BaseLLMException) as exc_info:
|
||||
llm_passthrough_route(
|
||||
model="gigachat/GigaChat-2",
|
||||
endpoint="chat/completions",
|
||||
method="POST",
|
||||
custom_llm_provider="gigachat",
|
||||
api_base="https://gigachat.devices.sberbank.ru/api/v1",
|
||||
api_key="fake-key",
|
||||
json={
|
||||
"model": "GigaChat-2",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": True,
|
||||
},
|
||||
client=sync_client,
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
provider_config=mock_provider_config,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 429
|
||||
assert "Rate limit exceeded" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_llm_passthrough_route_propagates_allm_passthrough_route_to_logging_obj():
|
||||
"""
|
||||
Regression guard for LIT-4192: `allm_passthrough_route` sets
|
||||
|
|
|
|||
|
|
@ -4865,3 +4865,76 @@ class TestPassthroughRouterModelBudgetReservation:
|
|||
)
|
||||
|
||||
self._assert_metadata_carries_attribution(captured, user_api_key_dict)
|
||||
|
||||
|
||||
class TestAzureRouterModelStreamingDispatch:
|
||||
"""
|
||||
Regression: ``llm_router.allm_passthrough_route`` returns an awaited
|
||||
``AsyncPassthroughStreamingResponse`` for streaming calls, which is no
|
||||
longer an async generator under ``inspect.isasyncgen``. The dispatch's
|
||||
else branch therefore calls ``.aiter_bytes()`` / ``.status_code`` /
|
||||
``.headers`` on it. The router's ``set_response_headers`` also runs the
|
||||
result through ``prepare_response_for_header_attachment``, which used to
|
||||
wrap it in ``HiddenParamsAsyncIteratorWrapper`` (no ``aiter_bytes``), so
|
||||
every streaming Azure router-model request 500'd with
|
||||
``AttributeError: aiter_bytes``; ``_hidden_params`` on the streaming
|
||||
response keeps it unwrapped.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_router_model_streaming_returns_streaming_response(self, monkeypatch):
|
||||
import litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints as ep
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm.passthrough.main import AsyncPassthroughStreamingResponse
|
||||
|
||||
upstream_body = b"data: hello\n\n"
|
||||
|
||||
async def _upstream_response() -> httpx.Response:
|
||||
upstream_request = httpx.Request(
|
||||
"POST",
|
||||
"https://my-azure.openai.azure.com/openai/deployments/gpt-5/chat/completions",
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=upstream_body,
|
||||
request=upstream_request,
|
||||
)
|
||||
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.async_flush_passthrough_collected_chunks = AsyncMock()
|
||||
|
||||
from litellm.router_utils.add_retry_fallback_headers import prepare_response_for_header_attachment
|
||||
|
||||
class StreamingRouter:
|
||||
async def allm_passthrough_route(self, **kwargs):
|
||||
streaming_response = await AsyncPassthroughStreamingResponse(
|
||||
response=_upstream_response(),
|
||||
litellm_logging_obj=logging_obj,
|
||||
provider_config=MagicMock(),
|
||||
)
|
||||
return prepare_response_for_header_attachment(streaming_response)
|
||||
|
||||
async def fake_get_request_body(_request):
|
||||
return {"model": "gpt-5", "stream": True}
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", StreamingRouter())
|
||||
monkeypatch.setattr(ep, "get_request_body", fake_get_request_body)
|
||||
monkeypatch.setattr(ep, "is_passthrough_request_using_router_model", lambda *a, **k: True)
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.method = "POST"
|
||||
request.headers = {"content-type": "application/json"}
|
||||
request.query_params = {}
|
||||
|
||||
result = await azure_proxy_route(
|
||||
endpoint="openai/deployments/gpt-5/chat/completions",
|
||||
request=request,
|
||||
fastapi_response=MagicMock(spec=Response),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-token"),
|
||||
)
|
||||
|
||||
assert isinstance(result, StreamingResponse)
|
||||
assert result.status_code == 200
|
||||
body = b"".join([chunk async for chunk in result.body_iterator])
|
||||
assert body == upstream_body
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue