diff --git a/litellm/llms/openrouter/chat/transformation.py b/litellm/llms/openrouter/chat/transformation.py index 86e63fd0c41..8d3ab8bdce5 100644 --- a/litellm/llms/openrouter/chat/transformation.py +++ b/litellm/llms/openrouter/chat/transformation.py @@ -278,7 +278,16 @@ class OpenRouterChatCompletionStreamingHandler(BaseModelResponseIterator): new_choices = [] for choice in chunk["choices"]: - choice["delta"]["reasoning_content"] = choice["delta"].get("reasoning") + delta = choice["delta"] + delta["reasoning_content"] = delta.get("reasoning") + + # Promote annotations (url_citation) from choice level to + # delta so they flow through the streaming handler. + # Some providers put them in delta, others at choice level. + annotations = delta.get("annotations") or choice.pop("annotations", None) + if annotations is not None: + delta["annotations"] = annotations + new_choices.append(choice) return ModelResponseStream( id=chunk["id"], diff --git a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py index d5a73b3fd12..e874b0c694c 100644 --- a/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py +++ b/tests/test_litellm/llms/openrouter/chat/test_openrouter_chat_transformation.py @@ -84,6 +84,95 @@ class TestOpenRouterChatCompletionStreamingHandler: assert "KeyError" in str(exc_info.value) assert exc_info.value.status_code == 400 + def test_chunk_parser_passes_through_annotations_in_delta(self): + """Annotations in delta (url_citation) must not be silently dropped.""" + handler = OpenRouterChatCompletionStreamingHandler( + streaming_response=None, sync_stream=True + ) + + annotations = [ + { + "type": "url_citation", + "url_citation": { + "url": "https://example.com", + "title": "Example", + "start_index": 0, + "end_index": 10, + }, + } + ] + chunk = { + "id": "test_id", + "created": 1234567890, + "model": "perplexity/sonar-pro", + "choices": [ + { + "delta": { + "content": "Hello", + "reasoning": None, + "annotations": annotations, + }, + "index": 0, + } + ], + } + + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["annotations"] == annotations + + def test_chunk_parser_passes_through_annotations_on_choice(self): + """Some providers put annotations at the choice level, not inside delta.""" + handler = OpenRouterChatCompletionStreamingHandler( + streaming_response=None, sync_stream=True + ) + + annotations = [ + { + "type": "url_citation", + "url_citation": { + "url": "https://example.com/alt", + "title": "Alt Source", + "start_index": 5, + "end_index": 15, + }, + } + ] + chunk = { + "id": "test_id", + "created": 1234567890, + "model": "openai/gpt-5.1", + "choices": [ + { + "delta": {"content": "World", "reasoning": None}, + "annotations": annotations, + "index": 0, + } + ], + } + + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["annotations"] == annotations + # Choice-level annotations must be removed after promotion to delta + assert result.choices[0].get("annotations") is None + + def test_chunk_parser_no_annotations_no_regression(self): + """When no annotations are present, delta must not gain an annotations key.""" + handler = OpenRouterChatCompletionStreamingHandler( + streaming_response=None, sync_stream=True + ) + + chunk = { + "id": "test_id", + "created": 1234567890, + "model": "test_model", + "choices": [ + {"delta": {"content": "hi", "reasoning": None}, "index": 0} + ], + } + + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"].get("annotations") is None + def test_openrouter_extra_body_transformation(): transformed_request = OpenrouterConfig().transform_request(