diff --git a/litellm/llms/apodex/responses/transformation.py b/litellm/llms/apodex/responses/transformation.py index e8014905846..065b7a475ec 100644 --- a/litellm/llms/apodex/responses/transformation.py +++ b/litellm/llms/apodex/responses/transformation.py @@ -29,6 +29,7 @@ from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfi from litellm.types.llms.openai import ( ResponsesAPIOptionalRequestParams, ResponsesAPIResponse, + ResponsesAPIStreamingResponse, ) from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -108,6 +109,48 @@ class ApodexResponsesConfig(OpenAIResponsesAPIConfig): logging_obj=logging_obj, ) + def transform_streaming_response( + self, + model: str, + parsed_chunk: dict, # mutable-ok: matches the base-class signature + logging_obj: LiteLLMLoggingObj, + ) -> ResponsesAPIStreamingResponse: + """Surface the Deep Research answer text as the OpenAI delta event callers expect. + + Observed live, not documented: a Deep Research stream carries its text in + `response.swarm.llm_delta` and never emits `response.output_text.delta`, so + without this the answer arrives only in the final `response.completed` + snapshot. `channel` splits the agent's reasoning from its answer; everything + else falls through to the base class as a GenericEvent. + """ + swarm: Final = parsed_chunk.get("swarm") + swarm_data: Final = swarm.get("data") if isinstance(swarm, dict) else None + if ( + parsed_chunk.get("type") != "response.swarm.llm_delta" + or not isinstance(swarm_data, dict) + or swarm_data.get("channel") != "output_text" + or not isinstance(swarm_data.get("delta"), str) + ): + return super().transform_streaming_response( + model=model, + parsed_chunk=parsed_chunk, + logging_obj=logging_obj, + ) + + response_id: Final = str(parsed_chunk.get("response_id", "")) + return super().transform_streaming_response( + model=model, + parsed_chunk={ # mutable-ok: JSON event payload + "type": "response.output_text.delta", + "item_id": f"msg_{response_id}", + "output_index": 0, + "content_index": 0, + "delta": swarm_data["delta"], + "sequence_number": parsed_chunk.get("sequence_number", 0), + }, + logging_obj=logging_obj, + ) + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: matches the base-class signature inherited: Final = super().get_supported_openai_params(model) if is_deep_research_model(model): diff --git a/tests/test_litellm/llms/apodex/test_apodex_responses_transformation.py b/tests/test_litellm/llms/apodex/test_apodex_responses_transformation.py index ac8e6db7cc4..789911126c3 100644 --- a/tests/test_litellm/llms/apodex/test_apodex_responses_transformation.py +++ b/tests/test_litellm/llms/apodex/test_apodex_responses_transformation.py @@ -231,6 +231,71 @@ class TestDeepResearchKeepsState: assert response.status == "cancelled" assert response._hidden_params["headers"]["x-ratelimit-remaining-requests"] == "42" + def test_output_text_delta_is_normalized(self): + """A Deep Research stream never emits response.output_text.delta of its own. + + Payload shape captured from a live stream; the extra swarm.data keys ride + along untouched and must not affect the mapping. + """ + config = _responses_config("apodex-1-1-deep-research") + event = config.transform_streaming_response( + model="apodex-1-1-deep-research", + parsed_chunk={ + "type": "response.swarm.llm_delta", + "created_at": 1786873219.3543231, + "response_id": "w_c4b77c96", + "sequence_number": 12, + "swarm": { + "agent_id": "stateful_react", + "data": { + "channel": "output_text", + "delta": "Hello there, friend!", + "delta_index": 0, + "call_id": "llm_fce8e965", + "turn": 1, + }, + }, + }, + logging_obj=None, + ) + + assert event.type == "response.output_text.delta" + assert event.item_id == "msg_w_c4b77c96" + assert event.delta == "Hello there, friend!" + assert event.sequence_number == 12 + + @pytest.mark.parametrize( + "channel", + ("reasoning", None), + ids=("reasoning-channel", "no-channel"), + ) + def test_non_answer_deltas_are_not_claimed_as_output_text(self, channel): + """Most of the stream is the agent thinking; only `output_text` is the answer.""" + data = {"delta": "The"} if channel is None else {"channel": channel, "delta": "The"} + config = _responses_config("apodex-1-1-deep-research") + event = config.transform_streaming_response( + model="apodex-1-1-deep-research", + parsed_chunk={ + "type": "response.swarm.llm_delta", + "response_id": "w_c4b77c96", + "sequence_number": 7, + "swarm": {"agent_id": "stateful_react", "data": data}, + }, + logging_obj=None, + ) + + assert event.type == "response.swarm.llm_delta" + + def test_documented_events_pass_through_untouched(self): + config = _responses_config("apodex-1-1-deep-research") + event = config.transform_streaming_response( + model="apodex-1-1-deep-research", + parsed_chunk={"type": "response.in_progress", "sequence_number": 2}, + logging_obj=None, + ) + + assert event.type == "response.in_progress" + def test_non_json_cancel_body_raises_the_provider_error(self): """The gateway answers a timed-out cancel with an HTML 504, not the JSON envelope.""" config = _responses_config("apodex-1-1-deep-research")