From 94d4a4dce09781344788eed6f41e6c393d321d92 Mon Sep 17 00:00:00 2001 From: Harshit28j Date: Fri, 20 Mar 2026 18:44:23 +0530 Subject: [PATCH] fix: address req changes --- litellm/litellm_core_utils/litellm_logging.py | 24 ++++--- litellm/utils.py | 5 +- .../test_get_litellm_params.py | 52 +++++++++++++- .../test_litellm_logging.py | 67 ++++++++++++++++--- 4 files changed, 127 insertions(+), 21 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index e10f8e28ae9..c77e572ec5f 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -590,19 +590,25 @@ class Logging(LiteLLMLoggingBaseClass): kwargs["litellm_metadata"], dict ): base_litellm_params["litellm_metadata"] = kwargs["litellm_metadata"] - if "metadata" not in base_litellm_params: - base_litellm_params["metadata"] = kwargs["litellm_metadata"].copy() - else: - # Merge litellm_metadata into metadata without overwriting existing - # keys so API key fields are visible to callbacks even when - # Anthropic's native metadata is present (/v1/messages). - for key, value in kwargs["litellm_metadata"].items(): - if key not in base_litellm_params["metadata"]: - base_litellm_params["metadata"][key] = value if litellm_params: base_litellm_params.update(litellm_params) + # Merge litellm_metadata into metadata AFTER .update(litellm_params) so + # the merge isn't silently overwritten. This ensures API key fields + # (user_api_key_hash, etc.) are visible to callbacks even when the + # request uses "litellm_metadata" (e.g. /v1/messages from Claude Code). + if "litellm_metadata" in kwargs and isinstance( + kwargs["litellm_metadata"], dict + ): + if "metadata" not in base_litellm_params: + base_litellm_params["metadata"] = dict(kwargs["litellm_metadata"]) + else: + base_litellm_params["metadata"] = dict(base_litellm_params["metadata"]) + for key, value in kwargs["litellm_metadata"].items(): + if key not in base_litellm_params["metadata"]: + base_litellm_params["metadata"][key] = value + self.update_environment_variables( litellm_params=base_litellm_params, optional_params=optional_params or {}, diff --git a/litellm/utils.py b/litellm/utils.py index 51b8efe6f23..8e336515ef1 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1155,12 +1155,15 @@ def function_setup( # noqa: PLR0915 # populate litellm_params["metadata"] so callbacks (e.g. Langfuse) that # read API key info from litellm_params["metadata"] see the fields. if not litellm_params.get("metadata"): - litellm_params["metadata"] = kwargs["litellm_metadata"].copy() + litellm_params["metadata"] = dict(kwargs["litellm_metadata"]) else: # Merge litellm_metadata into metadata without overwriting existing # keys. This ensures API key fields (user_api_key_hash, etc.) are # visible to callbacks even when Anthropic's native metadata field # is present in /v1/messages requests from Claude Code. + litellm_params["metadata"] = dict( + litellm_params["metadata"] + ) # don't mutate caller's dict for key, value in kwargs["litellm_metadata"].items(): if key not in litellm_params["metadata"]: litellm_params["metadata"][key] = value diff --git a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py index b39943b3e49..a9f68d478aa 100644 --- a/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py +++ b/tests/test_litellm/litellm_core_utils/test_get_litellm_params.py @@ -4,7 +4,6 @@ Tests for get_litellm_params and related helpers. Ensures backward compatibility after sparse kwargs extraction optimization. """ -import pytest from litellm.litellm_core_utils.get_litellm_params import ( _OPTIONAL_KWARGS_KEYS, @@ -126,3 +125,54 @@ class TestGetLitellmParamsExplicitFields: result = get_litellm_params(no_log=True) assert result["no-log"] is True + +class TestGetLitellmParamsLitellmMetadataMerge: + """Verify litellm_metadata is merged into metadata for callback visibility.""" + + def test_merge_when_no_metadata(self): + """When only litellm_metadata is provided, it becomes metadata.""" + lm_meta = {"user_api_key_hash": "hashed-abc", "team_id": "t-1"} + result = get_litellm_params(litellm_metadata=lm_meta) + + assert result["metadata"] == lm_meta + # Must be a copy, not the same object + assert result["metadata"] is not lm_meta + + def test_merge_preserves_existing_metadata_keys(self): + """Existing metadata keys are not overwritten by litellm_metadata.""" + metadata = {"user_api_key": "sk-real", "trace_id": "t-1"} + lm_meta = { + "user_api_key_hash": "hashed-abc", + "user_api_key": "should-not-overwrite", + } + result = get_litellm_params(metadata=metadata, litellm_metadata=lm_meta) + + assert result["metadata"]["user_api_key"] == "sk-real" + assert result["metadata"]["trace_id"] == "t-1" + assert result["metadata"]["user_api_key_hash"] == "hashed-abc" + + def test_merge_does_not_mutate_caller_metadata(self): + """The caller's original metadata dict must not be mutated.""" + original_metadata = {"user_api_key": "sk-real"} + lm_meta = {"user_api_key_hash": "hashed-abc"} + result = get_litellm_params( + metadata=original_metadata, litellm_metadata=lm_meta + ) + + assert "user_api_key_hash" not in original_metadata + assert result["metadata"] is not original_metadata + + def test_merge_does_not_mutate_caller_litellm_metadata(self): + """The caller's original litellm_metadata dict must not be mutated.""" + lm_meta = {"user_api_key_hash": "hashed-abc"} + original_keys = set(lm_meta.keys()) + get_litellm_params(litellm_metadata=lm_meta) + + assert set(lm_meta.keys()) == original_keys + + def test_no_merge_when_litellm_metadata_is_none(self): + """When litellm_metadata is None, metadata is returned as-is.""" + metadata = {"user_api_key": "sk-real"} + result = get_litellm_params(metadata=metadata, litellm_metadata=None) + + assert result["metadata"] == metadata diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index d9b7f14d297..5cf2da8e135 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -33,7 +33,7 @@ def test_get_masked_api_base(logging_obj): api_base = "https://api.openai.com/v1" masked_api_base = logging_obj._get_masked_api_base(api_base) assert masked_api_base == "https://api.openai.com/v1" - assert type(masked_api_base) == str + assert isinstance(masked_api_base, str) def test_sentry_sample_rate(): @@ -222,16 +222,55 @@ class TestUpdateFromKwargs: assert logging_obj.litellm_params["metadata"] == lm_meta - def test_no_backfill_when_metadata_already_present(self, logging_obj): - metadata = {"user_api_key": "sk-real"} - lm_meta = {"model_info": {"id": "deploy-1"}} + def test_merges_litellm_metadata_into_existing_metadata(self, logging_obj): + """When both metadata and litellm_metadata exist, litellm_metadata keys + should be merged into metadata without overwriting existing keys.""" + metadata = {"user_api_key": "sk-real", "trace_id": "t-1"} + lm_meta = { + "user_api_key_hash": "hashed-abc", + "user_api_key": "should-not-overwrite", + } kwargs = {"metadata": metadata, "litellm_metadata": lm_meta} logging_obj.update_from_kwargs(kwargs=kwargs) - assert logging_obj.litellm_params["metadata"] == metadata + result_meta = logging_obj.litellm_params["metadata"] + # Existing key preserved + assert result_meta["user_api_key"] == "sk-real" + assert result_meta["trace_id"] == "t-1" + # New key merged from litellm_metadata + assert result_meta["user_api_key_hash"] == "hashed-abc" + # litellm_metadata also stored assert logging_obj.litellm_params["litellm_metadata"] == lm_meta + def test_merge_does_not_mutate_original_kwargs_metadata(self, logging_obj): + """The merge must not mutate the caller's original metadata dict.""" + original_metadata = {"user_api_key": "sk-real"} + lm_meta = {"user_api_key_hash": "hashed-abc", "team_id": "t-1"} + kwargs = {"metadata": original_metadata, "litellm_metadata": lm_meta} + + logging_obj.update_from_kwargs(kwargs=kwargs) + + # Original dict should be untouched + assert "user_api_key_hash" not in original_metadata + assert "team_id" not in original_metadata + + def test_merge_survives_litellm_params_update(self, logging_obj): + """When caller passes litellm_params with its own metadata, litellm_metadata + keys should still be merged in (merge happens after .update()).""" + caller_metadata = {"from_caller": True} + lm_meta = {"user_api_key_hash": "hashed-xyz"} + kwargs = {"litellm_metadata": lm_meta} + + logging_obj.update_from_kwargs( + kwargs=kwargs, + litellm_params={"metadata": caller_metadata, "litellm_call_id": "x"}, + ) + + result_meta = logging_obj.litellm_params["metadata"] + assert result_meta["from_caller"] is True + assert result_meta["user_api_key_hash"] == "hashed-xyz" + def test_caller_litellm_params_win_over_kwargs(self, logging_obj): """Explicit litellm_params from the caller should override auto-extracted values.""" kwargs = {"metadata": {"from_kwargs": True}} @@ -241,7 +280,7 @@ class TestUpdateFromKwargs: litellm_params={"metadata": {"from_caller": True}, "litellm_call_id": "x"}, ) - assert logging_obj.litellm_params["metadata"] == {"from_caller": True} + assert logging_obj.litellm_params["metadata"]["from_caller"] is True def test_custom_pricing_detected_via_litellm_metadata(self, logging_obj): """Custom pricing in litellm_metadata.model_info should set custom_pricing flag.""" @@ -1901,8 +1940,8 @@ def test_function_setup_litellm_metadata_populates_metadata(): def test_function_setup_metadata_takes_precedence_over_litellm_metadata(): """ Test that when BOTH metadata and litellm_metadata are present (e.g., user sets - Anthropic API metadata AND proxy adds litellm_metadata), metadata is used as - litellm_params["metadata"] and litellm_metadata is stored separately. + Anthropic API metadata AND proxy adds litellm_metadata), existing metadata keys + are preserved and litellm_metadata keys are merged in without overwriting. """ import litellm @@ -1916,6 +1955,7 @@ def test_function_setup_metadata_takes_precedence_over_litellm_metadata(): "litellm_metadata": { "user_api_key_hash": "sk-hashed-xyz", "user_api_key_team_id": "team-xyz", + "user_id": "should-not-overwrite", }, } @@ -1928,16 +1968,23 @@ def test_function_setup_metadata_takes_precedence_over_litellm_metadata(): litellm_params = logging_obj.model_call_details.get("litellm_params", {}) - # When both are present, metadata should be the explicit "metadata" dict + # Existing metadata key preserved (not overwritten by litellm_metadata) metadata = litellm_params.get("metadata") assert metadata is not None assert metadata.get("user_id") == "anthropic-user-id" - # litellm_metadata should be preserved separately for merge_litellm_metadata() + # litellm_metadata keys merged into metadata + assert metadata.get("user_api_key_hash") == "sk-hashed-xyz" + assert metadata.get("user_api_key_team_id") == "team-xyz" + + # litellm_metadata should be preserved separately litellm_metadata = litellm_params.get("litellm_metadata") assert litellm_metadata is not None assert litellm_metadata.get("user_api_key_hash") == "sk-hashed-xyz" + # metadata must be a copy — mutating it must not affect the original kwargs dict + assert metadata is not kwargs["metadata"] + def test_function_setup_empty_metadata_falls_back_to_litellm_metadata(): """