diff --git a/strix/config/models.py b/strix/config/models.py index babb643f..c01b1ccb 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -561,6 +561,16 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings( ), ) +_RESPONSE_FAILED_EVENT_TYPE = "response.failed" +_RESPONSE_FAILED_DEFAULT_MESSAGE = "Response failed" +_RESPONSE_FAILED_STATUS_CODE = 500 +_RESPONSE_ERROR_ATTR = "error" +_RESPONSE_ERROR_CODE_ATTR = "code" +_RESPONSE_ERROR_MESSAGE_ATTR = "message" +_RESPONSE_EVENT_TYPE_ATTR = "type" +_RESPONSE_EVENT_RESPONSE_ATTR = "response" +_LITELLM_RESPONSE_FAILED_PATCH_ATTR = "_strix_raises_response_failed" + RECOMMENDED_MODEL_NAMES = ( "zai/glm-5.3", "zai/glm-5.3-flash", @@ -661,6 +671,7 @@ def _configure_litellm_compatibility() -> None: _register_litellm_cost_callback() _install_openrouter_stream_cost_capture() + _patch_litellm_response_failed_streaming_errors() def _install_openrouter_stream_cost_capture() -> None: @@ -707,6 +718,64 @@ def _install_openrouter_stream_cost_capture() -> None: litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc] +def _patch_litellm_response_failed_streaming_errors() -> None: + """Make LiteLLM raise on Responses API ``response.failed`` stream events.""" + try: + from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator + except Exception: # noqa: BLE001 + return + + original_process_chunk = getattr(BaseResponsesAPIStreamingIterator, "_process_chunk", None) + if not callable(original_process_chunk): + return + if getattr(original_process_chunk, _LITELLM_RESPONSE_FAILED_PATCH_ATTR, False): + return + + def _strix_process_chunk(self: Any, chunk: Any) -> Any: + result = original_process_chunk(self, chunk) + _raise_litellm_response_failed(result, self) + return result + + setattr(_strix_process_chunk, _LITELLM_RESPONSE_FAILED_PATCH_ATTR, True) + BaseResponsesAPIStreamingIterator._process_chunk = _strix_process_chunk # type: ignore[method-assign] + + +def _raise_litellm_response_failed(chunk: Any, iterator: Any) -> None: + if _value(chunk, _RESPONSE_EVENT_TYPE_ATTR) != _RESPONSE_FAILED_EVENT_TYPE: + return + + from litellm.exceptions import APIError + + response = _value(chunk, _RESPONSE_EVENT_RESPONSE_ATTR) + error = _value(response, _RESPONSE_ERROR_ATTR) + message = _response_failed_message(error) + raise APIError( + status_code=_RESPONSE_FAILED_STATUS_CODE, + message=message, + llm_provider=str(getattr(iterator, "custom_llm_provider", "") or ""), + model=str(getattr(iterator, "model", "") or ""), + ) + + +def _response_failed_message(error: Any) -> str: + message = _value(error, _RESPONSE_ERROR_MESSAGE_ATTR) + if not isinstance(message, str) or not message: + message = str(error) if error else _RESPONSE_FAILED_DEFAULT_MESSAGE + + code = _value(error, _RESPONSE_ERROR_CODE_ATTR) + if isinstance(code, str) and code and code not in message: + return f"{message} ({code})" + return message + + +def _value(source: Any, key: str) -> Any: + if source is None: + return None + if isinstance(source, dict): + return source.get(key) + return getattr(source, key, None) + + OPENROUTER_ATTRIBUTION_HEADERS = { "HTTP-Referer": "https://strix.ai", "X-Title": "Strix", diff --git a/tests/test_litellm_response_failed.py b/tests/test_litellm_response_failed.py new file mode 100644 index 00000000..9b810381 --- /dev/null +++ b/tests/test_litellm_response_failed.py @@ -0,0 +1,56 @@ +"""Tests for LiteLLM Responses API failed stream events.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any + +import pytest +from litellm.exceptions import APIError +from litellm.responses.streaming_iterator import BaseResponsesAPIStreamingIterator + +from strix.config.models import _configure_litellm_compatibility + + +FAILED_EVENT_TYPE = "response.failed" +FAILED_MESSAGE = "Insufficient credits" +FAILED_CODE = "billing_hard_limit_reached" +FAILED_MODEL = "openai/gpt-5" +FAILED_PROVIDER = "openai" + + +class _ProviderConfig: + def transform_streaming_response(self, **_kwargs: object) -> SimpleNamespace: + return SimpleNamespace( + type=FAILED_EVENT_TYPE, + response=SimpleNamespace( + error=SimpleNamespace(message=FAILED_MESSAGE, code=FAILED_CODE) + ), + ) + + +def _make_iterator() -> Any: + iterator: Any = BaseResponsesAPIStreamingIterator.__new__(BaseResponsesAPIStreamingIterator) + iterator.responses_api_provider_config = _ProviderConfig() + iterator.logging_obj = SimpleNamespace() + iterator.model = FAILED_MODEL + iterator.custom_llm_provider = FAILED_PROVIDER + iterator.litellm_metadata = None + iterator.completed_response = None + iterator._handle_logging_failed_response = lambda: None + iterator._handle_logging_completed_response = lambda: None + return iterator + + +def test_response_failed_stream_event_is_raised() -> None: + _configure_litellm_compatibility() + iterator = _make_iterator() + payload = json.dumps({"type": FAILED_EVENT_TYPE, "response": {"error": {}}}) + + with pytest.raises(APIError) as exc_info: + iterator._process_chunk(payload) + + error_text = str(exc_info.value) + assert FAILED_MESSAGE in error_text + assert FAILED_CODE in error_text