diff --git a/litellm/interactions/background_cost_polling.py b/litellm/interactions/background_cost_polling.py index 0b8c128286a..7894f4099e5 100644 --- a/litellm/interactions/background_cost_polling.py +++ b/litellm/interactions/background_cost_polling.py @@ -307,8 +307,13 @@ def _spend_metadata(logging_obj: "LiteLLMLoggingObj") -> Mapping[str, object]: rebuilt logging object has no request headers to derive them from. """ litellm_params: Final = _OBJECT_DICT.validate_python(logging_obj.model_call_details.get("litellm_params") or {}) + metadata: Final = _OBJECT_MAPPING.validate_python( + get_litellm_metadata_from_kwargs(kwargs=logging_obj.model_call_details) + ) + if metadata.get("tags") and not isinstance(metadata.get("tags"), list): + return metadata return { - **_OBJECT_MAPPING.validate_python(get_litellm_metadata_from_kwargs(kwargs=logging_obj.model_call_details)), + **metadata, **_request_tags(litellm_params), } diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 9c49efc529f..c07392a8a5f 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -7573,9 +7573,9 @@ class StandardLoggingPayloadSetup: ) request_tags: Final = next( ( - cast(list[str], tags) # cast-ok: tags are caller-provided - for tags in (metadata.get("tags"), litellm_metadata.get("tags")) - if isinstance(tags, list) and tags + cast(list[str], tags) # cast-ok: preserve existing caller-provided list elements + for tag_metadata in (metadata, litellm_metadata) + if isinstance(tags := tag_metadata.get("tags"), list) and tags ), (), ) diff --git a/tests/unit/interactions/test_background_cost_polling.py b/tests/unit/interactions/test_background_cost_polling.py index d55994dfb19..994aeca83e7 100644 --- a/tests/unit/interactions/test_background_cost_polling.py +++ b/tests/unit/interactions/test_background_cost_polling.py @@ -484,13 +484,14 @@ async def test_schedule_registers_an_agent_only_create_that_names_no_model(): @pytest.mark.asyncio -async def test_schedule_registers_a_create_whose_tags_are_a_string_and_keeps_them_as_sent(): +@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"]) +async def test_schedule_registers_a_create_whose_tags_are_a_string_and_keeps_them_as_sent(metadata_field): """ The regression: storing the create's context read string tags as a list and raised, so a create sent with a tags string answered with an error after the provider had already started the interaction. """ - logging_obj = _logging_obj(litellm_params={"metadata": _create_metadata(tags="team-a,nightly-batch")}) + logging_obj = _logging_obj(litellm_params={metadata_field: _create_metadata(tags="team-a,nightly-batch")}) store = InMemoryBackgroundSettlementStore() task = await maybe_schedule_background_interaction_cost_polling( diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index e8ea0c04f6f..cdafa608bf5 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -3675,6 +3675,58 @@ def test_get_request_tags_preserves_non_string_metadata_tags(): assert 7 in tags +@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"]) +@pytest.mark.parametrize("invalid_tags", ["my-tag", 7, True, {"tag": "value"}, ("my-tag",), None, ""]) +def test_standard_logging_payload_ignores_non_list_tags( + logging_obj: LitellmLogging, metadata_key: str, invalid_tags: object +) -> None: + timestamp: Final = datetime.datetime(2026, 1, 1) + payload: Final = get_standard_logging_object_payload( + kwargs={ + "litellm_call_id": "invalid-tag-request", + "model": "test-model", + "messages": [], + "litellm_params": { + metadata_key: {"tags": invalid_tags}, + "proxy_server_request": {"headers": {"user-agent": "litellm/1.0.0"}}, + }, + }, + init_response_obj={}, + start_time=timestamp, + end_time=timestamp, + logging_obj=logging_obj, + status="success", + ) + + assert payload is not None + assert payload["litellm_call_id"] == "invalid-tag-request" + assert payload["request_tags"] == ["User-Agent: litellm", "User-Agent: litellm/1.0.0"] + + +@pytest.mark.parametrize("invalid_tags", ["my-tag", 7, {"tag": "value"}, ("my-tag",), [], None]) +def test_get_request_tags_uses_valid_fallback_tags(invalid_tags: object) -> None: + fallback_tags: Final = ["fallback-tag"] + tags: Final = StandardLoggingPayloadSetup.get_request_tags( + litellm_params={ + "metadata": {"tags": invalid_tags}, + "litellm_metadata": {"tags": fallback_tags}, + }, + proxy_server_request={"headers": {"user-agent": "litellm/1.0.0"}}, + ) + + assert tags == ["fallback-tag", "User-Agent: litellm", "User-Agent: litellm/1.0.0"] + assert fallback_tags == ["fallback-tag"] + + +def test_get_request_tags_skips_unused_malformed_metadata() -> None: + tags: Final = StandardLoggingPayloadSetup.get_request_tags( + litellm_params={"metadata": {"tags": ["primary-tag"]}, "litellm_metadata": "unused"}, + proxy_server_request={}, + ) + + assert tags == ["primary-tag"] + + def test_get_request_tags_from_metadata_and_litellm_metadata(): """ Test that _get_request_tags correctly picks tags from both 'metadata' and 'litellm_metadata'.