From c62e1238d648607f9544a98bcba4b4ec8ad5e369 Mon Sep 17 00:00:00 2001 From: Chenlu Ji Date: Mon, 6 Jul 2026 22:31:48 -0700 Subject: [PATCH 01/30] feat(tinyfish): surface response headers + top-level response extras Follow-up to #31411 (superseded and merged as #31997). Two related fixes so LiteLLM callers see what TinyFish actually returns, plus small correctness cleanups. ## Response headers surfaced on _hidden_params TinyFish sets useful response headers (x-request-id on every response, retry-after and x-ratelimit-limit on 429s). Previously these were only accessible via BaseLLMException.headers on error paths; on the success path they were dropped entirely. Fix: stash headers on both LiteLLM-conventional channels, matching the pattern used by Gemini / Volcengine / Manus / ChatGPT / OpenAI-responses providers. - `_hidden_params["headers"]` -- raw dict from httpx, all keys lowercased. - `_hidden_params["additional_headers"]` -- passed through process_response_headers, which prefixes any x-litellm-* provider header with `llm_provider-` so downstream LiteLLM code that trusts bare x-litellm-* markers can't be spoofed (values still survive under the prefixed key for observability). ## Top-level response extras (query, total_results, page, future fields) transform_search_response was building a fresh SearchResponse from just `results`, silently dropping every top-level field TinyFish's response carries beyond `results` / `object`. Fix: mutate parsed.results to its truncated slice and return the same SearchResponse instance rather than reconstructing. Every field pydantic populated during model_validate -- declared attributes AND extras (query, total_results, page, parameter_warnings, and any future TinyFish additions) -- survives regardless of which storage bucket holds it. Robust against upstream schema evolution: if LiteLLM later promotes a field from extras to declared, this code needs no change. ## Code cleanup - List-valued custom params JSON-encoded on the wire (matching the existing dict handling), so callers can pass a natural Python list for JSON-array wire params. - URL-encodable-params adapter accepts float in addition to str / int / bool; server-side rejection of a wrong-typed float now surfaces cleanly with `TinyFish Search:` attribution + docs link. - Assorted comment / docstring / test-fixture hygiene (no logic changes). ## Tests 70 unit + integration tests pass locally. Live-tested against production TinyFish with 6 diverse queries (basic / max_results / country=US / language=ja / domain filter / fetch={"format":"html"}) -- all 6 pass every expected-behavior check. --- .../llms/tinyfish/search/transformation.py | 74 +++++--- tests/search_tests/test_tinyfish_search.py | 61 ++++++- .../llms/tinyfish/test_tinyfish_search.py | 165 +++++++++++++++--- 3 files changed, 250 insertions(+), 50 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index cef5f9cd02e..aea0dfe8b8e 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -14,6 +14,7 @@ import httpx from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_logger +from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.search.transformation import ( @@ -22,13 +23,13 @@ from litellm.llms.base_llm.search.transformation import ( ) from litellm.secret_managers.main import get_secret_str -_UrlEncodableParams = TypeAdapter(dict[str, str | int | bool]) +_UrlEncodableParams = TypeAdapter(dict[str, str | int | float | bool]) _StrList = TypeAdapter(list[str]) _StrFrozenSet = TypeAdapter(frozenset[str]) _TINYFISH_PARAMS_KEY = "_tinyfish_params" _TINYFISH_DOCS_URL = "https://docs.tinyfish.ai/search-api" -_TINYFISH_RESULT_CAP = 10 # TinyFish's natural per-page SERP ceiling +_TINYFISH_RESULT_CAP = 10 # Client-side truncation cap for max_results class TinyfishSearchConfig(BaseSearchConfig): @@ -94,16 +95,16 @@ class TinyfishSearchConfig(BaseSearchConfig): TinyFish equivalents: - ``query`` (str or list[str]) → ``query`` (list joined by spaces) - ``country`` → ``location`` - - ``search_domain_filter`` (list[str]) → folded into the query as - ``() (site:a OR site:b ...)`` (TinyFish has no first-class - field today; see ML-2084 for the planned ``include_domains``) + - ``search_domain_filter`` (list[str]) → folded into the query using + search operators - ``max_results`` → not sent on the wire; stashed on ``self._caller_max_results`` for client-side response truncation (TinyFish doesn't honor it server-side) - ``max_tokens_per_page`` → silently dropped (no TinyFish equivalent) Any other ``optional_params`` keys are forwarded to TinyFish as-is. - dict/list values are JSON-encoded so they survive ``urlencode``. + dict and list values are JSON-encoded so structured payloads survive + ``urlencode``. Returns: ``{_TINYFISH_PARAMS_KEY: }``. @@ -144,14 +145,12 @@ class TinyfishSearchConfig(BaseSearchConfig): supported_perplexity = _StrFrozenSet.validate_python(raw_supported) for param, value in optional_params.items(): if param not in supported_perplexity and param not in request_data: - # `fetch` expects a JSON-encoded object on the wire; accept the - # natural Python dict form and serialize here so callers don't - # have to pre-stringify. - if isinstance(value, dict): + # Serialize dicts/lists as JSON so structured params survive urlencode. + if isinstance(value, (dict, list)): value = json.dumps(value, separators=(",", ":")) # `urlencode` would render Python bool as "True"/"False" - # (capitalized). ux-labs validators require lowercase - # "true"/"false" (e.g. `include_thumbnail`); normalize here. + # (capitalized). TinyFish Search's bool params require lowercase + # "true"/"false" strings on the wire; normalize here. elif isinstance(value, bool): value = "true" if value else "false" request_data[param] = value @@ -167,17 +166,35 @@ class TinyfishSearchConfig(BaseSearchConfig): """ Transform a TinyFish response to LiteLLM's unified ``SearchResponse``. - Mappings (per-result): - - ``title`` → ``SearchResult.title`` (defaults to ``""`` if missing/null) - - ``url`` → ``SearchResult.url`` (defaults to ``""``) - - ``snippet`` → ``SearchResult.snippet`` (defaults to ``""``) - - all other per-result fields (``position``, ``site_name``, - ``thumbnail_url``, ``fetch``, ``fetch_error``, ...) ride through as - extras on ``SearchResult`` via its ``extra="allow"`` config. + Per-result field handling: + - ``title``, ``url``, ``snippet`` are declared on ``SearchResult`` and + populated by ``SearchResponse.model_validate`` when present. Missing + or ``None`` values are defaulted to ``""`` beforehand by + ``_default_missing_result_fields`` so a degraded result flows through + instead of failing the whole call. + - All undeclared per-result fields (``position``, ``site_name``, and + any others TinyFish returns) ride through as extras via + ``SearchResult``'s ``extra="allow"`` config — accessible as + attributes on the result object or enumerable via + ``result.model_extra``. - Top-level ``parameter_warnings`` (see ML-2085) is read when present and - each entry is re-fired via ``verbose_logger.warning``. Absent or - malformed entries are silently skipped — never throws. + Top-level ``parameter_warnings`` is read when present and each entry + is re-fired via ``verbose_logger.warning``. Absent or malformed + entries are silently skipped — never throws. + + Top-level extras (``query``, ``total_results``, ``page``, and any + future TinyFish additions) ride through via + ``SearchResponse.extra="allow"``. The validated response is returned + in place after truncating ``results`` to the caller's ``max_results``, + so every field pydantic populated survives regardless of which + storage bucket (declared attribute or ``__pydantic_extra__``) holds it. + + TinyFish response headers (e.g. ``x-request-id``, ``retry-after``, + ``x-ratelimit-limit`` — httpx normalizes header names to lowercase) + are stashed on ``response._hidden_params["headers"]`` (raw) and + ``response._hidden_params["additional_headers"]`` (sanitized via + ``process_response_headers``) so callers can correlate a search with + server-side logs. Error paths routed through ``self._wrap_error`` for uniform ``"TinyFish Search: . See for details."`` wrapping: @@ -223,7 +240,12 @@ class TinyfishSearchConfig(BaseSearchConfig): _emit_parameter_warnings(parsed) max_results = self._caller_max_results or _TINYFISH_RESULT_CAP - return SearchResponse(results=list(parsed.results[:max_results])) + # Truncate in place so all pydantic-populated fields survive — declared and extras. + parsed.results = list(parsed.results[:max_results]) + raw_headers = dict(raw_response.headers) + parsed._hidden_params["headers"] = raw_headers + parsed._hidden_params["additional_headers"] = process_response_headers(raw_headers) + return parsed def _wrap_error( self, @@ -243,9 +265,9 @@ class TinyfishSearchConfig(BaseSearchConfig): carry the ``TinyFish Search:`` prefix — the bare error already names the host in the URL, so attribution is implicit there. """ - # ux-labs frontend wraps every error body as {"error": {"code", "message", "details"?}}. + # TinyFish Search wraps every error body as {"error": {"code", "message", "details"?}}. # Best-effort unwrap to surface the inner message; fall back to the raw body - # for non-ux-labs responses (CDN HTML pages, other JSON envelopes, plain text). + # for other envelope shapes (CDN HTML pages, other JSON envelopes, plain text). inner_message = error_message try: body: object = json.loads(error_message) # any-ok: json.loads -> Any @@ -290,7 +312,7 @@ def _default_missing_result_fields(raw_json: object) -> None: def _emit_parameter_warnings(parsed: SearchResponse) -> None: - """Re-fire TinyFish-side ``parameter_warnings`` (see ML-2085) as warnings. + """Re-fire TinyFish-side ``parameter_warnings`` as warnings. Defensive: skip silently on any shape we don't recognize so a malformed entry (or an early/partial rollout of the field) never throws. diff --git a/tests/search_tests/test_tinyfish_search.py b/tests/search_tests/test_tinyfish_search.py index aca28544513..becb8287a29 100644 --- a/tests/search_tests/test_tinyfish_search.py +++ b/tests/search_tests/test_tinyfish_search.py @@ -35,11 +35,16 @@ MOCK_TINYFISH_RESPONSE = { def _make_mock_response( - json_data: dict, status_code: int = 200, request_url: str | None = None + json_data: dict, + status_code: int = 200, + request_url: str | None = None, + headers: dict | None = None, ) -> MagicMock: mock = MagicMock() mock.status_code = status_code mock.json.return_value = json_data + # httpx.Headers normalizes keys to lowercase — mirror production behavior. + mock.headers = httpx.Headers(headers or {}) if request_url: mock.request = MagicMock() mock.request.url = httpx.URL(request_url) @@ -163,7 +168,7 @@ class TestTinyfishSearch: @pytest.mark.asyncio async def test_fetch_param_round_trip(self): - # End-to-end check: caller passes `fetch=...` (JSON-encoded tf-fetch + # End-to-end check: caller passes `fetch=...` (JSON-encoded fetch # config); param reaches TinyFish on the request side and the nested # `fetch` object on each result surfaces back to the SearchResult on the # response side. No LiteLLM-side support code is required. @@ -235,6 +240,58 @@ class TestTinyfishSearch: assert result.results[0].title == "Result 0" assert result.results[2].title == "Result 2" + @pytest.mark.asyncio + async def test_top_level_extras_surface_end_to_end(self): + # Envelope extras (`query`, `total_results`, `page`) must survive the + # full asearch dispatch — proves LiteLLM's entry-point plumbing outside + # our transformer doesn't accidentally strip them. + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + response = await litellm.asearch( + query="web automation tools", + search_provider="tinyfish", + ) + + assert getattr(response, "query", None) == "web automation tools" + assert getattr(response, "total_results", None) == 2 + assert getattr(response, "page", None) == 0 + + @pytest.mark.asyncio + async def test_response_headers_surface_end_to_end(self): + # Response headers must land on `_hidden_params` after the full + # asearch dispatch (both raw and sanitized channels). + os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" + + mock_response = _make_mock_response( + MOCK_TINYFISH_RESPONSE, + headers={"X-Request-ID": "req-e2e-1"}, + ) + + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get", + new_callable=AsyncMock, + ) as mock_get: + mock_get.return_value = mock_response + + response = await litellm.asearch( + query="test", + search_provider="tinyfish", + ) + + raw = response._hidden_params["headers"] + add = response._hidden_params["additional_headers"] + # httpx lowercases; both channels agree on the value. + assert raw["x-request-id"] == "req-e2e-1" + assert add["llm_provider-x-request-id"] == "req-e2e-1" + @pytest.mark.asyncio async def test_empty_results(self): os.environ["TINYFISH_API_KEY"] = "sk-tinyfish-test" diff --git a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py index 58363e3baea..2dcccb8ea7e 100644 --- a/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py +++ b/tests/test_litellm/llms/tinyfish/test_tinyfish_search.py @@ -47,7 +47,9 @@ def _make_mock_response( mock = MagicMock() mock.status_code = status_code - mock.headers = headers or {} + # httpx.Headers normalizes keys to lowercase — mirror production so tests + # assert what callers actually see. + mock.headers = httpx.Headers(headers or {}) if json_data is not None: mock.json.return_value = json_data mock.text = text if text is not None else _json.dumps(json_data) @@ -222,7 +224,7 @@ class TestTransformSearchRequest: assert param not in result["_tinyfish_params"] def test_arbitrary_param_passed_through(self): - # `fetch` is a TinyFish-specific param (JSON-encoded tf-fetch config). + # `fetch` is a TinyFish-specific param (JSON-encoded fetch config). # The passthrough loop should forward it verbatim without LiteLLM needing # to know about it. config = TinyfishSearchConfig() @@ -237,26 +239,49 @@ class TestTransformSearchRequest: config = TinyfishSearchConfig() result = config.transform_search_request( query="test", - optional_params={"fetch": {"format": "html", "fetch_path": "fast"}}, - ) - assert ( - result["_tinyfish_params"]["fetch"] - == '{"format":"html","fetch_path":"fast"}' + optional_params={"fetch": {"format": "html"}}, ) + assert result["_tinyfish_params"]["fetch"] == '{"format":"html"}' def test_bool_param_serialized_as_lowercase(self): - # urlencode renders Python bool as capitalized "True"/"False"; ux-labs - # rejects those (e.g. include_thumbnail must be literal "true"/"false"). - # Normalize before passing through. + # urlencode renders Python bool as capitalized "True"/"False"; TinyFish + # Search's bool params require lowercase "true"/"false" strings on the + # wire. Normalize before passing through. config = TinyfishSearchConfig() true_result = config.transform_search_request( - query="test", optional_params={"include_thumbnail": True} + query="test", optional_params={"some_bool_param": True} ) false_result = config.transform_search_request( - query="test", optional_params={"include_thumbnail": False} + query="test", optional_params={"some_bool_param": False} ) - assert true_result["_tinyfish_params"]["include_thumbnail"] == "true" - assert false_result["_tinyfish_params"]["include_thumbnail"] == "false" + assert true_result["_tinyfish_params"]["some_bool_param"] == "true" + assert false_result["_tinyfish_params"]["some_bool_param"] == "false" + + def test_float_param_passes_through(self): + # Float values pass the urlencode adapter and land on the wire as + # their decimal string form. If TinyFish's server rejects a float + # for a param it expects as int, the server's 400 response is + # attributed via _wrap_error (`TinyFish Search: ...`) — better than + # a client-side pydantic ValidationError with no context. + config = TinyfishSearchConfig() + result = config.transform_search_request( + query="test", + optional_params={"some_float_param": 0.5}, + ) + assert result["_tinyfish_params"]["some_float_param"] == 0.5 + + def test_list_param_auto_json_encoded(self): + # TinyFish Search's JSON-array params arrive on the wire as JSON- + # encoded strings. Accept the natural Python list form and serialize + # so the caller doesn't have to pre-stringify. Params whose wire + # format is a plain comma-separated string are the caller's + # responsibility to pass as a Python str. + config = TinyfishSearchConfig() + result = config.transform_search_request( + query="test", + optional_params={"some_list_param": ["a.example", "b.example"]}, + ) + assert result["_tinyfish_params"]["some_list_param"] == '["a.example","b.example"]' def test_pre_stringified_param_passed_unchanged(self): # If the caller already JSON-encoded, don't re-encode. @@ -422,10 +447,104 @@ class TestTransformSearchResponse: assert getattr(first, "position", None) == 1 assert getattr(first, "site_name", None) == "tinyfish.ai" + def test_top_level_extras_flow_through(self): + # TinyFish returns `query`, `total_results`, `page` at the envelope + # level. These must ride through to the caller via SearchResponse's + # extra="allow" so pagination logic, echo checks, etc. work. + config = TinyfishSearchConfig() + mock_response = _make_mock_response(MOCK_TINYFISH_RESPONSE) + result = config.transform_search_response( + raw_response=mock_response, logging_obj=None + ) + assert getattr(result, "query", None) == "web automation tools" + assert getattr(result, "total_results", None) == 2 + assert getattr(result, "page", None) == 0 + + def test_top_level_future_extras_flow_through(self): + # Any future TinyFish top-level field must ride through unchanged + # (design contract: no LiteLLM code change needed for new fields). + config = TinyfishSearchConfig() + body = { + "results": [ + {"title": "x", "url": "https://x", "snippet": "x"}, + ], + "query": "test", + "example_int_extra": 123, # hypothetical future field + "example_str_extra": "value", # hypothetical future field + "example_id_extra": "abc-def", # hypothetical future field + } + result = config.transform_search_response( + raw_response=_make_mock_response(body), logging_obj=None + ) + assert getattr(result, "example_int_extra", None) == 123 + assert getattr(result, "example_str_extra", None) == "value" + assert getattr(result, "example_id_extra", None) == "abc-def" + + def test_response_headers_stashed_on_hidden_params(self): + # TinyFish Search sets X-Request-ID on every success response. Confirm it + # lands on both `_hidden_params["headers"]` (raw) and + # `_hidden_params["additional_headers"]` (sanitized/prefixed). + # httpx.Headers lowercases every key, so assertions use lowercase. + config = TinyfishSearchConfig() + mock_response = _make_mock_response( + MOCK_TINYFISH_RESPONSE, + headers={"X-Request-ID": "req-abc-123", "Content-Type": "application/json"}, + ) + result = config.transform_search_response( + raw_response=mock_response, logging_obj=None + ) + # Raw copy — httpx has normalized keys to lowercase. + assert result._hidden_params["headers"]["x-request-id"] == "req-abc-123" + # process_response_headers prefixes non-OpenAI-standard keys with "llm_provider-". + assert result._hidden_params["additional_headers"]["llm_provider-x-request-id"] == "req-abc-123" + + def test_response_headers_future_headers_flow_through(self): + # "Accept extra": any header TinyFish Search adds later must ride + # through without a LiteLLM code change. + config = TinyfishSearchConfig() + mock_response = _make_mock_response( + MOCK_TINYFISH_RESPONSE, + headers={ + "X-Request-ID": "req-1", + "X-Example-Header-A": "value-a", # hypothetical future header + "X-Example-Header-B": "value-b", # hypothetical future header + }, + ) + result = config.transform_search_response( + raw_response=mock_response, logging_obj=None + ) + raw = result._hidden_params["headers"] + # httpx lowercases header names on read. + assert raw["x-example-header-a"] == "value-a" + assert raw["x-example-header-b"] == "value-b" + + def test_response_headers_strips_x_litellm_spoof(self): + # A provider setting `x-litellm-*` in its response must not be able to + # spoof LiteLLM-internal markers via _hidden_params["additional_headers"]. + # The raw copy preserves the header (opt-in debug view); the sanitized + # copy prefixes it with `llm_provider-` so bare `x-litellm-*` markers + # can't be spoofed (values still survive under the prefixed key for + # observability). + config = TinyfishSearchConfig() + mock_response = _make_mock_response( + MOCK_TINYFISH_RESPONSE, + headers={"x-litellm-attempted-fallbacks": "spoofed", "X-Request-ID": "r1"}, + ) + result = config.transform_search_response( + raw_response=mock_response, logging_obj=None + ) + # Raw view still has the spoof. + assert result._hidden_params["headers"]["x-litellm-attempted-fallbacks"] == "spoofed" + # Sanitized view: the spoof survives only under the llm_provider- prefix + # (never under the bare x-litellm-* key that LiteLLM downstream trusts). + additional = result._hidden_params["additional_headers"] + assert "x-litellm-attempted-fallbacks" not in additional + assert additional.get("llm_provider-x-litellm-attempted-fallbacks") == "spoofed" + def test_fetch_field_rides_through_to_search_result(self): - # Mirrors browser-search's per-result `fetch` nested object (see - # api/src/parser.rs SearchResult.fetch). Confirms `fetch=...` requests - # surface their content to LiteLLM callers without provider changes. + # Mirrors TinyFish Search's per-result `fetch` nested object. + # Confirms `fetch=...` requests surface their content to LiteLLM + # callers without provider changes. config = TinyfishSearchConfig() fetched = { "results": [ @@ -568,7 +687,7 @@ class TestTransformSearchResponse: class TestErrorHandling: def test_4xx_response_raises_with_attribution_and_unwrapped_message(self): - # Reproduces ux-labs' error envelope shape for an INVALID_INPUT response. + # Reproduces TinyFish Search's error envelope shape for an INVALID_INPUT response. config = TinyfishSearchConfig() body = { "error": { @@ -590,7 +709,7 @@ class TestErrorHandling: def test_429_preserves_status_code_and_headers(self): config = TinyfishSearchConfig() - body = {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "60 rpm"}} + body = {"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "rate limit exceeded"}} mock_response = _make_mock_response( body, status_code=429, headers={"Retry-After": "60"} ) @@ -600,10 +719,12 @@ class TestErrorHandling: ) assert getattr(exc_info.value, "status_code", None) == 429 headers = getattr(exc_info.value, "headers", {}) or {} - assert headers.get("Retry-After") == "60" + # httpx lowercases; the exception carries the same dict shape. + assert headers.get("retry-after") == "60" - def test_5xx_with_non_ux_labs_body_falls_back_to_raw_text(self): - # Cloudflare-style JSON or any other envelope: unwrap fails, fall back to raw. + def test_5xx_with_non_tinyfish_envelope_shape_falls_back_to_raw_text(self): + # A JSON body that doesn't match TinyFish Search's error envelope shape: + # unwrap fails, fall back to the raw body text. config = TinyfishSearchConfig() body = {"errors": [{"code": "10000", "message": "Internal"}]} mock_response = _make_mock_response(body, status_code=502) From 3b843708b097d6c4ff96d374900282ddf5142f0d Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Jul 2026 20:17:57 +0000 Subject: [PATCH 02/30] fix(bedrock): degrade gracefully on malformed tool-call arguments split_concatenated_json_objects re-raised JSONDecodeError on genuinely malformed (non-concatenated) tool-call arguments, which propagated out of _convert_to_bedrock_tool_call_invoke and turned every replayed Bedrock conversation into a 500. Catch the decode error, keep whatever complete objects parsed, log a warning, and let the caller fall back to input={} so the conversation continues. Fixes #18667 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../prompt_templates/common_utils.py | 27 +++++++++--- ...ore_utils_prompt_templates_common_utils.py | 30 +++++++++++-- ...llm_core_utils_prompt_templates_factory.py | 43 +++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 538d5f650ef..db3856ce86b 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1679,16 +1679,19 @@ def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]: This helper uses ``json.JSONDecoder.raw_decode()`` to walk the string and extract each JSON object individually. + The walk degrades gracefully: if the string is malformed or truncated + (e.g. a stream that ended mid-tool-call), whatever complete objects were + parsed before the bad tail are returned and the remainder is discarded + with a warning, rather than raising. The sole caller + (``_convert_to_bedrock_tool_call_invoke``) treats an empty result as + ``input={}`` so the conversation can continue instead of hard-failing. + Returns ------- list[dict] A list of parsed dicts – one per JSON object found. If *raw* is - empty or whitespace-only, an empty list is returned. - - Raises - ------ - json.JSONDecodeError - If the string contains text that cannot be parsed as JSON at all. + empty, whitespace-only, or wholly unparseable, an empty list is + returned. """ import json @@ -1708,7 +1711,17 @@ def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]: if idx >= length: break - obj, end_idx = decoder.raw_decode(raw, idx) + try: + obj, end_idx = decoder.raw_decode(raw, idx) + except json.JSONDecodeError as e: + verbose_logger.warning( + "split_concatenated_json_objects: discarding unparseable tool-call " + "arguments tail after %d complete object(s); error=%s at char %d", + len(results), + e, + idx, + ) + break if isinstance(obj, dict): results.append(obj) else: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 1b1db634ed2..6d14d283b84 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -251,10 +251,32 @@ def test_split_concatenated_json_non_dict_value(): assert result == [{}] -def test_split_concatenated_json_invalid_raises(): - """Completely invalid JSON raises JSONDecodeError.""" - with pytest.raises(json.JSONDecodeError): - split_concatenated_json_objects("not json at all") +def test_split_concatenated_json_wholly_invalid_returns_empty(): + """ + Wholly unparseable JSON degrades to an empty list instead of raising. + + Regression for https://github.com/BerriAI/litellm/issues/18667: a raise + here propagated out of `_convert_to_bedrock_tool_call_invoke` and turned + every replayed conversation into a 500. + """ + assert split_concatenated_json_objects("not json at all") == [] + + +def test_split_concatenated_json_malformed_object_returns_empty(): + """ + A single malformed object (missing comma between keys) degrades to an + empty list rather than raising `Expecting ',' delimiter`. + """ + assert split_concatenated_json_objects('{"location": "Boston" "unit": "celsius"}') == [] + + +def test_split_concatenated_json_salvages_prefix_before_truncated_tail(): + """ + Complete objects parsed before an unparseable/truncated tail are kept; + only the bad tail is discarded. + """ + result = split_concatenated_json_objects('{"a": 1}{"b": 2}{"c":') + assert result == [{"a": 1}, {"b": 2}] # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index bcda88ea609..17f680df4d9 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -2286,6 +2286,49 @@ def test_bedrock_tool_call_invoke_non_dict_arguments(): assert result[0]["toolUse"]["input"] == {} +def test_bedrock_tool_call_invoke_malformed_json_does_not_raise(): + """ + Regression for https://github.com/BerriAI/litellm/issues/18667. + + When the model emits malformed JSON in tool-call arguments (here a + missing comma between keys), replaying that history must NOT raise + `Unable to convert openai tool calls ... Expecting ',' delimiter`. + It degrades to an empty-object input so the conversation can continue. + """ + tool_calls = [ + { + "id": "toolu_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Boston" "unit": "celsius"}', + }, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + assert len(result) == 1 + assert result[0]["toolUse"]["toolUseId"] == "toolu_abc123" + assert result[0]["toolUse"]["name"] == "get_weather" + assert result[0]["toolUse"]["input"] == {} + + +def test_bedrock_tool_call_invoke_salvages_valid_prefix_before_truncated_tail(): + """ + A valid leading object followed by a truncated tail keeps the valid + object rather than dropping everything or raising. + """ + tool_calls = [ + { + "id": "call_partial", + "type": "function", + "function": {"name": "shell", "arguments": '{"cmd": "ls"}{"cmd":'}, + } + ] + result = _convert_to_bedrock_tool_call_invoke(tool_calls) + assert len(result) == 1 + assert result[0]["toolUse"]["input"] == {"cmd": "ls"} + + def test_make_valid_bedrock_tool_name_preserves_hyphens(): assert make_valid_bedrock_tool_name("my-tool") == "my-tool" assert ( From c2d8a4e4263e45bee96e94f7091071140bf79d83 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 18 Jul 2026 20:28:35 +0000 Subject: [PATCH 03/30] chore(bedrock): clarify tool-call decode warning to avoid double char reference Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/prompt_templates/common_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index db3856ce86b..9dcbca954b3 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1716,10 +1716,10 @@ def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]: except json.JSONDecodeError as e: verbose_logger.warning( "split_concatenated_json_objects: discarding unparseable tool-call " - "arguments tail after %d complete object(s); error=%s at char %d", + "arguments tail after %d complete object(s); decode_start=%d error=%s", len(results), - e, idx, + e, ) break if isinstance(obj, dict): From 89e563d3dabdede841f48a717c74fb21794223a8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:37:03 -0700 Subject: [PATCH 04/30] fix(tinyfish): keep hidden-params header stashing within lint budgets --- litellm/llms/tinyfish/search/transformation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/llms/tinyfish/search/transformation.py b/litellm/llms/tinyfish/search/transformation.py index ae11b4bcc57..b688dc2cd01 100644 --- a/litellm/llms/tinyfish/search/transformation.py +++ b/litellm/llms/tinyfish/search/transformation.py @@ -240,11 +240,11 @@ class TinyfishSearchConfig(BaseSearchConfig): _emit_parameter_warnings(parsed) max_results: Final = self._caller_max_results or _TINYFISH_RESULT_CAP - # Truncate in place so all pydantic-populated fields survive — declared and extras. - parsed.results = list(parsed.results[:max_results]) - raw_headers = dict(raw_response.headers) - parsed._hidden_params["headers"] = raw_headers - parsed._hidden_params["additional_headers"] = process_response_headers(raw_headers) + parsed.results = parsed.results[:max_results] + raw_headers: Final = dict(raw_response.headers) + hidden: Final = parsed._hidden_params # pyright: ignore[reportPrivateUsage] # sole hidden-params channel + hidden["headers"] = raw_headers + hidden["additional_headers"] = process_response_headers(raw_headers) return parsed def _wrap_error( From 96cee087bebad1fa215c8ce1051e31ba730e5f06 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:49:12 -0700 Subject: [PATCH 05/30] test(e2e): pin auto-router tag-split, alias pricing, heuristic scope, and Responses routing regressions --- tests/e2e/coverage_registry/reliability.yaml | 9 + tests/e2e/models.py | 4 + .../test_auto_router_regressions_e2e.py | 632 ++++++++++++++++++ 3 files changed, 645 insertions(+) create mode 100644 tests/e2e/router/test_auto_router_regressions_e2e.py diff --git a/tests/e2e/coverage_registry/reliability.yaml b/tests/e2e/coverage_registry/reliability.yaml index ebbfd3415a5..5a1d437eac9 100644 --- a/tests/e2e/coverage_registry/reliability.yaml +++ b/tests/e2e/coverage_registry/reliability.yaml @@ -18,6 +18,15 @@ - {id: reliability.routing.usage_based.picks_under_tpm, module: reliability, tier: P0, behavior: routing, variant: usage_based, assertions: [picks_under_tpm], exercised_on: [chat_completions, messages], source: "router_strategy/lowest_tpm_rpm_v2.py", rationale: "Routes to lowest-TPM deployment; prevents over-allocation"} - {id: reliability.routing.least_busy.picks_lowest_traffic, module: reliability, tier: P1, behavior: routing, variant: least_busy, assertions: [picks_lowest_traffic], exercised_on: [chat_completions, messages], source: "router_strategy/least_busy.py", rationale: "Fewest in-flight requests"} - {id: reliability.routing.complexity_llm_classifier.routes_by_llm_tier, module: reliability, tier: P1, behavior: routing, variant: complexity_llm_classifier, assertions: [routes_by_llm_tier], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py", fail_before_fix: proven, rationale: "v2 auto-router LLM complexity classifier runs over the proxy and routes by semantic tier instead of silently crashing on absent litellm_metadata and falling back to heuristic scoring"} +- {id: reliability.routing.tagged_marker.request_tag_selects_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [request_tag_selects_marker], exercised_on: [chat_completions], source: "litellm/router.py:11445", rationale: "Tagged request selects the tagged strategy marker under a shared model_name instead of the plain deployment registered first (GitHub issue #36619)"} +- {id: reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [untagged_request_served_by_plain_deployment], exercised_on: [chat_completions, messages, responses], source: "litellm/router.py:11445", rationale: "Untagged requests to a shared model_name are served by the plain deployment on every call, never captured or errored by the tagged marker (GitHub issue #36620)"} +- {id: reliability.routing.tagged_marker.header_tag_selects_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [header_tag_selects_marker], exercised_on: [messages], source: "litellm/router.py:11445", rationale: "A request tagged only via the x-litellm-tags header selects the tagged marker on Anthropic-native /v1/messages (GitHub issue #36621)"} +- {id: reliability.routing.tagged_marker.untagged_tier_deployments_still_served, module: reliability, tier: P1, behavior: routing, variant: tagged_marker, assertions: [untagged_tier_deployments_still_served], exercised_on: [chat_completions, messages], source: "litellm/router_strategy/tag_based_routing.py:433", rationale: "Routing tags the marker consumed no longer constrain deployment selection inside the routed tier group, so untagged tier deployments serve the rewrite (GitHub issue #36621)"} +- {id: reliability.routing.tagged_marker.tag_semantics_stay_strict, module: reliability, tier: P1, behavior: routing, variant: tagged_marker, assertions: [tag_semantics_stay_strict], exercised_on: [chat_completions], source: "litellm/router_strategy/tag_based_routing.py:299", rationale: "Tag consumption must not loosen strict semantics: a tagged call aimed straight at an untagged deployment still gets the 401 tags-configuration denial"} +- {id: reliability.routing.tagged_marker.responses_input_routes_through_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [responses_input_routes_through_marker], exercised_on: [responses], source: "litellm/router.py:11489", rationale: "Tagged /v1/responses (header or litellm_metadata.tags, string or list input) routes through the marker to its tier, extending the GitHub issues #36620/#36621 tag split to the Responses surface"} +- {id: reliability.routing.semantic_auto_router.responses_input_routed, module: reliability, tier: P0, behavior: routing, variant: semantic_auto_router, assertions: [responses_input_routed], exercised_on: [responses], source: "litellm/router_strategy/auto_router/auto_router.py:131", fail_before_fix: proven, rationale: "/v1/responses input is resolved into messages for the semantic auto-router pre-routing hook instead of failing 400 Unmapped LLM provider auto_router (GitHub PR #37333)"} +- {id: reliability.routing.strategy_alias.custom_pricing_ignored, module: reliability, tier: P1, behavior: routing, variant: strategy_alias, assertions: [custom_pricing_ignored], exercised_on: [chat_completions], source: "litellm/router.py:11489", rationale: "Custom pricing on a strategy-router alias never prices the routed request; spend logs at the routed tier deployment's own rate (GitHub PR #36691)"} +- {id: reliability.routing.complexity_heuristic.scores_current_ask_only, module: reliability, tier: P1, behavior: routing, variant: complexity_heuristic, assertions: [scores_current_ask_only], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py:942", rationale: "The heuristic complexity classifier scores the caller's current ask only, so a keyword-heavy agent system prompt cannot inflate the tier (GitHub PR #36721)"} - {id: reliability.cache.exact.returns_cached, module: reliability, tier: P1, behavior: cache, variant: exact, assertions: [returns_cached], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/caching.py", rationale: "Response cache returns cached on exact match"} - {id: reliability.cache.prompt_caching_model_select.returns_cached, module: reliability, tier: P1, behavior: cache, variant: prompt_caching_model_select, assertions: [returns_cached], exercised_on: [chat_completions], source: "router_utils/prompt_caching_cache.py", rationale: "Selects model supporting prompt caching for cacheable prefix"} - {id: reliability.circuit_breaker.redis.trips_then_recovers, module: reliability, tier: P0, behavior: circuit_breaker, variant: redis, assertions: [trips_then_recovers], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/redis_cache.py:99", rationale: "Redis breaker CLOSED->OPEN->HALF_OPEN; guards all cache/rate-limit ops"} diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 734e63a94e6..957da605546 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -744,6 +744,10 @@ class LiteLLMParamsBody(BaseModel): extra_headers: dict[str, str] | None = None use_in_pass_through: bool | None = None complexity_router_config: dict[str, object] | None = None + auto_router_config: str | None = None + auto_router_default_model: str | None = None + auto_router_embedding_model: str | None = None + tags: list[str] | None = None mock_response: str | None = None timeout: float | None = None tpm: int | None = None diff --git a/tests/e2e/router/test_auto_router_regressions_e2e.py b/tests/e2e/router/test_auto_router_regressions_e2e.py new file mode 100644 index 00000000000..ee68d3fa1bb --- /dev/null +++ b/tests/e2e/router/test_auto_router_regressions_e2e.py @@ -0,0 +1,632 @@ +"""Live e2e regression pins for strategy-router (auto-router) routing. + +A strategy marker (an ``auto_router/complexity_router`` deployment) and a plain +deployment can share one ``model_name``, split by tags once +``enable_tag_filtering`` is on: tagged requests route through the marker to its +tier models, untagged requests go to the plain deployment. That split, and the +strategy-router alias behaviors around it, regressed repeatedly; each test here +pins one fixed behavior: + +- GitHub issue #36619: a tagged request selects the tagged marker under a + shared name even when a plain deployment was registered first. +- GitHub issue #36620: untagged requests keep being served by the plain + deployment on every call, never captured or 400'd by the tagged marker. +- GitHub issue #36621: a request tagged via the ``x-litellm-tags`` header + routes through the marker even when the tier deployments carry no tags + (the marker consumes the routing tags before deployment selection), while a + tagged call aimed straight at an untagged deployment stays denied. +- GitHub issues #36620/#36621 on /v1/responses: the same tag split holds for + string and list input, whether the tag arrives in litellm_metadata or the + x-litellm-tags header. +- GitHub PR #37333: /v1/responses input is resolved into messages for a + semantic ``auto_router`` deployment's pre-routing hook; such requests used + to fail with 400 "Unmapped LLM provider auto_router" because only chat + messages fed the route matcher. +- GitHub PR #36691: custom pricing on the marker alias never prices the routed + request; spend logs at the routed tier deployment's own rate. +- GitHub PR #36721: the heuristic complexity classifier scores the caller's + current ask only, so a large agent system prompt cannot inflate the tier. + +Every deployment is registered via /model/new (stage has no static config for +these) and ``enable_tag_filtering`` is flipped through /config/update and +restored on teardown, mirroring TestRouterSettings in the management suite. +The served deployment is always read back from the spend log's ``model``, +which stores either the registered alias or the provider-prefixed form. +""" + +import json +import os +import time +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Final + +import pytest +from pydantic import BaseModel, ConfigDict, Field + +from e2e_config import unique_marker +from e2e_http import AnthropicHeaders, AuthHeaders, NoBody, UnauthorizedError, unwrap +from lifecycle import ResourceManager +from models import ( + AnthropicMessagesBody, + AnthropicMessagesResponse, + ChatBody, + ChatMessage, + ChatMetadata, + KeyGenerateBody, + LiteLLMParamsBody, + SpendLogRow, +) +from proxy_client import ProxyClient + +pytestmark = pytest.mark.e2e + +PLAIN_MODEL = "anthropic/claude-sonnet-5" +CHEAP_MODEL = "anthropic/claude-haiku-4-5" +STRONG_MODEL = "openai/gpt-5.6" +MAX_TOKENS = 16 +PLAIN_SERVED = frozenset({PLAIN_MODEL, "claude-sonnet-5"}) +CHEAP_SERVED = frozenset({CHEAP_MODEL, "claude-haiku-4-5"}) +EMBEDDING_MODEL = "openai/text-embedding-3-small" +SEMANTIC_ROUTE_UTTERANCE = "summarize this quarterly revenue report into three bullet points" + +KEYWORD_HEAVY_SYSTEM_PROMPT = ( + "You are the principal architecture assistant for a distributed systems platform. " + "Analyze every request step by step: design the algorithm, prove its correctness, " + "evaluate time and space complexity, and reason about concurrency, consistency, and " + "fault tolerance tradeoffs. When asked, refactor and debug multi-threaded code, " + "optimize database query plans, derive mathematical proofs, and explain the theorem " + "or lemma behind each optimization. Think through edge cases rigorously before answering. " +) * 4 + + +class TaggedAuthHeaders(AuthHeaders): + x_litellm_tags: str | None = Field(default=None, serialization_alias="x-litellm-tags") + + +class TaggedAnthropicHeaders(AnthropicHeaders): + x_litellm_tags: str | None = Field(default=None, serialization_alias="x-litellm-tags") + + +class ResponsesTagMetadata(BaseModel): + tags: list[str] + + +class ResponsesInputItem(BaseModel): + role: str + content: str + + +class ResponsesBody(BaseModel): + model: str + input: str | list[ResponsesInputItem] + max_output_tokens: int | None = None + litellm_metadata: ResponsesTagMetadata | None = None + + +class ResponsesApiResponse(BaseModel): + """Minimal /v1/responses answer shape; routing is proven from spend logs, + so only the fields the assertions read are modeled.""" + + model_config = ConfigDict(extra="allow") + id: str | None = None + status: str | None = None + model: str | None = None + + +class RouterSettingsPatch(BaseModel): + enable_tag_filtering: bool + + +class ConfigUpdateBody(BaseModel): + router_settings: RouterSettingsPatch + + +class ConfigUpdateResponse(BaseModel): + message: str + + +class RouterCurrentValues(BaseModel): + enable_tag_filtering: bool | None = None + + +class RouterSettingsResponse(BaseModel): + current_values: RouterCurrentValues + + +@dataclass(frozen=True, slots=True) +class TagSplitDeployments: + """Scenario A mirrors the customer-shaped config from GitHub issue #36619: + plain deployment registered first, tier deployment and marker both tagged. + Scenario B flips both axes for GitHub issue #36621: marker registered first + and its tier deployment left untagged, so routing depends neither on + registration order nor on tier deployments carrying tags.""" + + tag_a: str + shared_a: str + tier_a: str + tag_b: str + shared_b: str + tier_b: str + + +@dataclass(frozen=True, slots=True) +class ZeroPricedAlias: + alias: str + tier: str + + +@dataclass(frozen=True, slots=True) +class HeuristicSplit: + alias: str + cheap: str + strong: str + + +@dataclass(frozen=True, slots=True) +class SemanticAutoRouter: + marker: str + target: str + fallback: str + embedding: str + + +def _provider_key(env_var: str) -> str: + return os.environ.get(env_var) or f"os.environ/{env_var}" + + +def _uniform_tier_config(tier_model: str) -> dict[str, object]: + return { + "classifier_type": "heuristic", + "tiers": {"SIMPLE": tier_model, "MEDIUM": tier_model, "COMPLEX": tier_model, "REASONING": tier_model}, + } + + +def _read_tag_filtering(proxy: ProxyClient) -> bool | None: + return unwrap( + proxy.transport.get( + "/router/settings", + headers=proxy.transport.master, + params=NoBody(), + response_type=RouterSettingsResponse, + ) + ).current_values.enable_tag_filtering + + +def _write_tag_filtering(proxy: ProxyClient, enabled: bool) -> None: + response: Final = unwrap( + proxy.transport.post( + "/config/update", + headers=proxy.transport.master, + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(enable_tag_filtering=enabled)), + response_type=ConfigUpdateResponse, + ) + ) + assert "success" in response.message.lower(), ( + f"/config/update reported {response.message!r}, expected a success message" + ) + + +def _await_tag_filtering(proxy: ProxyClient, expected: bool) -> None: + deadline: Final = time.monotonic() + proxy.poll_timeout + while time.monotonic() < deadline: + if _read_tag_filtering(proxy) is expected: + return + time.sleep(proxy.poll_interval) + raise AssertionError( + f"GET /router/settings never reported enable_tag_filtering={expected} after /config/update" + ) + + +def _key_for(proxy: ProxyClient, resources: ResourceManager, models: list[str]) -> str: + key: Final = proxy.generate_key(KeyGenerateBody(models=models, user_id="e2e-auto-router-regressions")) + resources.defer(lambda: proxy.delete_key(key)) + return key + + +def _hello_chat_body(model: str, tags: list[str] | None = None) -> ChatBody: + return ChatBody( + model=model, + messages=[ChatMessage(role="user", content=f"say hello {unique_marker()}")], + max_tokens=MAX_TOKENS, + metadata=ChatMetadata(tags=tags) if tags is not None else None, + ) + + +def _hello_messages_body(model: str) -> AnthropicMessagesBody: + return AnthropicMessagesBody( + model=model, + messages=[ChatMessage(role="user", content=f"say hello {unique_marker()}")], + max_tokens=MAX_TOKENS, + ) + + +def _assert_served_only_by(rows: list[SpendLogRow], allowed: frozenset[str], context: str) -> None: + served: Final = tuple(row.model for row in rows) + assert served and all(model in allowed for model in served), ( + f"{context}: expected every request to be served by one of {sorted(allowed)}, spend logs show {served}" + ) + + +@pytest.fixture(scope="module") +def tag_filtering(proxy: ProxyClient) -> Iterator[None]: + """enable_tag_filtering is what splits tagged from untagged traffic in every + scenario here. /config/update is the only write path for router_settings; + the original value is restored on teardown so the shared proxy keeps its + configuration for the rest of the run.""" + original: Final = bool(_read_tag_filtering(proxy)) + _write_tag_filtering(proxy, True) + _await_tag_filtering(proxy, True) + try: + yield + finally: + _write_tag_filtering(proxy, original) + _await_tag_filtering(proxy, original) + + +@pytest.fixture(scope="module") +def split(proxy: ProxyClient, tag_filtering: None) -> Iterator[TagSplitDeployments]: + marker: Final = unique_marker() + deployments: Final = TagSplitDeployments( + tag_a=f"e2e-split-a-{marker}", + shared_a=f"e2e-autoroute-a-{marker}", + tier_a=f"e2e-tier-a-{marker}", + tag_b=f"e2e-split-b-{marker}", + shared_b=f"e2e-autoroute-b-{marker}", + tier_b=f"e2e-tier-b-{marker}", + ) + anthropic_key: Final = _provider_key("ANTHROPIC_API_KEY") + marker_params_a: Final = LiteLLMParamsBody( + model="auto_router/complexity_router", + complexity_router_config=_uniform_tier_config(deployments.tier_a), + tags=[deployments.tag_a], + ) + marker_params_b: Final = LiteLLMParamsBody( + model="auto_router/complexity_router", + complexity_router_config=_uniform_tier_config(deployments.tier_b), + tags=[deployments.tag_b], + ) + registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = ( + (deployments.shared_a, LiteLLMParamsBody(model=PLAIN_MODEL, api_key=anthropic_key)), + (deployments.tier_a, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=anthropic_key, tags=[deployments.tag_a])), + (deployments.shared_a, marker_params_a), + (deployments.shared_b, marker_params_b), + (deployments.tier_b, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=anthropic_key)), + (deployments.shared_b, LiteLLMParamsBody(model=PLAIN_MODEL, api_key=anthropic_key)), + ) + created: Final = tuple(proxy.create_model(name, params) for name, params in registrations) + try: + yield deployments + finally: + for model_id in created: + proxy.delete_model(model_id) + + +@pytest.fixture(scope="module") +def zero_priced_alias(proxy: ProxyClient) -> Iterator[ZeroPricedAlias]: + marker: Final = unique_marker() + named: Final = ZeroPricedAlias(alias=f"e2e-priced-alias-{marker}", tier=f"e2e-priced-tier-{marker}") + alias_params: Final = LiteLLMParamsBody( + model="auto_router/complexity_router", + complexity_router_config=_uniform_tier_config(named.tier), + input_cost_per_token=0.0, + output_cost_per_token=0.0, + ) + registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = ( + (named.tier, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))), + (named.alias, alias_params), + ) + created: Final = tuple(proxy.create_model(name, params) for name, params in registrations) + try: + yield named + finally: + for model_id in created: + proxy.delete_model(model_id) + + +@pytest.fixture(scope="module") +def heuristic_split(proxy: ProxyClient) -> Iterator[HeuristicSplit]: + marker: Final = unique_marker() + named: Final = HeuristicSplit( + alias=f"e2e-heuristic-router-{marker}", + cheap=f"e2e-heuristic-cheap-{marker}", + strong=f"e2e-heuristic-strong-{marker}", + ) + config: Final[dict[str, object]] = { + "classifier_type": "heuristic", + "token_thresholds": {"simple": 15, "complex": 400}, + "tiers": {"SIMPLE": named.cheap, "MEDIUM": named.strong, "COMPLEX": named.strong, "REASONING": named.strong}, + } + registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = ( + (named.cheap, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))), + (named.strong, LiteLLMParamsBody(model=STRONG_MODEL, api_key=_provider_key("OPENAI_API_KEY"))), + (named.alias, LiteLLMParamsBody(model="auto_router/complexity_router", complexity_router_config=config)), + ) + created: Final = tuple(proxy.create_model(name, params) for name, params in registrations) + try: + yield named + finally: + for model_id in created: + proxy.delete_model(model_id) + + +@pytest.fixture(scope="module") +def semantic_auto_router(proxy: ProxyClient) -> Iterator[SemanticAutoRouter]: + marker: Final = unique_marker() + named: Final = SemanticAutoRouter( + marker=f"e2e-semantic-router-{marker}", + target=f"e2e-semantic-target-{marker}", + fallback=f"e2e-semantic-fallback-{marker}", + embedding=f"e2e-semantic-embedding-{marker}", + ) + router_config: Final = json.dumps( + {"routes": [{"name": named.target, "utterances": [SEMANTIC_ROUTE_UTTERANCE], "score_threshold": 0.3}]} + ) + marker_params: Final = LiteLLMParamsBody( + model=f"auto_router/{named.marker}", + auto_router_config=router_config, + auto_router_default_model=named.fallback, + auto_router_embedding_model=named.embedding, + ) + registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = ( + (named.embedding, LiteLLMParamsBody(model=EMBEDDING_MODEL, api_key=_provider_key("OPENAI_API_KEY"))), + (named.target, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))), + (named.fallback, LiteLLMParamsBody(model=PLAIN_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))), + (named.marker, marker_params), + ) + created: Final = tuple(proxy.create_model(name, params) for name, params in registrations) + try: + yield named + finally: + for model_id in created: + proxy.delete_model(model_id) + + +class TestTagSplitRouting: + @pytest.mark.covers("reliability.routing.tagged_marker.request_tag_selects_marker") + def test_body_tagged_chat_routes_through_the_marker_to_its_tier( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins GitHub issue #36619: with tag filtering on, a chat request whose + body metadata tags match the tagged marker under a shared model name is + answered by the marker's tier deployment, not by the plain deployment + that was registered under the name first.""" + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + chat: Final = unwrap(proxy.chat(key, _hello_chat_body(split.shared_a, tags=[split.tag_a]))) + assert chat.choices, "tagged chat through the shared name returned no choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, CHEAP_SERVED | {split.tier_a}, "body-tagged chat on the shared name") + + @pytest.mark.covers("reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment") + def test_untagged_chat_is_always_served_by_the_plain_deployment( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins GitHub issue #36620: untagged chat requests to the shared name + succeed on every call and are all served by the plain deployment; the + tagged marker never captures them, so no intermittent auto-router + errors and no tier hijacking.""" + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + for _ in range(5): + chat = unwrap(proxy.chat(key, _hello_chat_body(split.shared_a))) + assert chat.choices, "untagged chat through the shared name returned no choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=5) + _assert_served_only_by(rows, PLAIN_SERVED | {split.shared_a}, "untagged chat on the shared name") + + @pytest.mark.covers("reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment") + def test_untagged_messages_is_served_by_the_plain_deployment( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins GitHub issue #36620 on the /v1/messages surface: an untagged + Anthropic-native request to the shared name is served by the plain + deployment, not captured by the tagged marker.""" + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + answer: Final = unwrap(proxy.messages(key, _hello_messages_body(split.shared_a))) + assert answer.content or answer.choices, "untagged /v1/messages returned neither content nor choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, PLAIN_SERVED | {split.shared_a}, "untagged /v1/messages on the shared name") + + +class TestUntaggedTierDeployments: + @pytest.mark.covers("reliability.routing.tagged_marker.header_tag_selects_marker") + def test_header_tagged_messages_routes_through_the_marker_to_an_untagged_tier( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins GitHub issue #36621: a /v1/messages request tagged only via the + x-litellm-tags header selects the tagged marker, and the rewrite still + lands on the tier deployment even though that deployment carries no + tags, because the marker consumed the routing tags.""" + key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b]) + headers: Final = TaggedAnthropicHeaders(authorization=f"Bearer {key}", x_litellm_tags=split.tag_b) + answer: Final = unwrap( + proxy.transport.post( + "/v1/messages", + headers=headers, + json=_hello_messages_body(split.shared_b), + response_type=AnthropicMessagesResponse, + ) + ) + assert answer.content or answer.choices, "header-tagged /v1/messages returned neither content nor choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, CHEAP_SERVED | {split.tier_b}, "header-tagged /v1/messages on the shared name") + + @pytest.mark.covers("reliability.routing.tagged_marker.untagged_tier_deployments_still_served") + def test_body_tagged_chat_reaches_the_untagged_tier_after_marker_rewrite( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins the tag-consumption half of GitHub issue #36621: after the + tagged marker rewrites the request to its tier model, the consumed + routing tags no longer constrain deployment selection, so the untagged + tier deployment serves the request instead of a strict-tag denial.""" + key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b]) + chat: Final = unwrap(proxy.chat(key, _hello_chat_body(split.shared_b, tags=[split.tag_b]))) + assert chat.choices, "body-tagged chat through the marker-first shared name returned no choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, CHEAP_SERVED | {split.tier_b}, "body-tagged chat with untagged tier") + + @pytest.mark.covers("reliability.routing.tagged_marker.tag_semantics_stay_strict") + def test_tagged_call_straight_at_an_untagged_deployment_stays_denied( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """The tag-consumption fix must not loosen strict tag semantics: a + tagged request aimed directly at an untagged deployment (no marker + involved) is still rejected with the 401 tags-configuration error.""" + key: Final = _key_for(proxy, resources, [split.tier_b]) + result: Final = proxy.chat(key, _hello_chat_body(split.tier_b, tags=[split.tag_b])) + assert isinstance(result, UnauthorizedError), ( + f"expected the tagged direct call to an untagged deployment to be denied with 401, got {result}" + ) + + +class TestResponsesApiTagRouting: + @pytest.mark.covers("reliability.routing.tagged_marker.responses_input_routes_through_marker") + def test_header_tagged_responses_with_string_input_routes_to_the_tier( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins the /v1/responses surface of the tag split (GitHub issues + #36620/#36621): a /v1/responses request with string input, tagged via + the x-litellm-tags header, succeeds and routes through the tagged + marker to its tier.""" + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + headers: Final = TaggedAuthHeaders(authorization=f"Bearer {key}", x_litellm_tags=split.tag_a) + body: Final = ResponsesBody( + model=split.shared_a, input=f"say hello {unique_marker()}", max_output_tokens=64 + ) + answer: Final = unwrap( + proxy.transport.post("/v1/responses", headers=headers, json=body, response_type=ResponsesApiResponse) + ) + assert answer.id, "header-tagged /v1/responses returned no response id" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, CHEAP_SERVED | {split.tier_a}, "header-tagged /v1/responses string input") + + @pytest.mark.covers("reliability.routing.tagged_marker.responses_input_routes_through_marker") + def test_body_tagged_responses_with_list_input_routes_to_the_tier( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins the body-tag and list-input combination of the same split: + /v1/responses with litellm_metadata.tags and structured input items + routes through the tagged marker to its tier.""" + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + body: Final = ResponsesBody( + model=split.shared_a, + input=[ResponsesInputItem(role="user", content=f"say hello {unique_marker()}")], + max_output_tokens=64, + litellm_metadata=ResponsesTagMetadata(tags=[split.tag_a]), + ) + answer: Final = unwrap( + proxy.transport.post( + "/v1/responses", + headers=proxy.transport.bearer(key), + json=body, + response_type=ResponsesApiResponse, + ) + ) + assert answer.id, "body-tagged /v1/responses returned no response id" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, CHEAP_SERVED | {split.tier_a}, "body-tagged /v1/responses list input") + + @pytest.mark.covers("reliability.routing.tagged_marker.untagged_request_served_by_plain_deployment") + def test_untagged_responses_is_served_by_the_plain_deployment( + self, proxy: ProxyClient, resources: ResourceManager, split: TagSplitDeployments + ) -> None: + """Pins the untagged half of the /v1/responses tag split: an untagged + request to the shared name is served by the plain deployment, matching + the chat and messages surfaces.""" + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + body: Final = ResponsesBody( + model=split.shared_a, input=f"say hello {unique_marker()}", max_output_tokens=64 + ) + answer: Final = unwrap( + proxy.transport.post( + "/v1/responses", + headers=proxy.transport.bearer(key), + json=body, + response_type=ResponsesApiResponse, + ) + ) + assert answer.id, "untagged /v1/responses returned no response id" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, PLAIN_SERVED | {split.shared_a}, "untagged /v1/responses on the shared name") + + +class TestStrategyAliasPricing: + @pytest.mark.covers("reliability.routing.strategy_alias.custom_pricing_ignored") + def test_zero_priced_alias_still_logs_spend_at_the_tier_rate( + self, proxy: ProxyClient, resources: ResourceManager, zero_priced_alias: ZeroPricedAlias + ) -> None: + """Pins GitHub PR #36691: custom pricing registered on a strategy-router + alias never prices the routed request. The alias here carries explicit + zero pricing, so any zero-spend row would prove the alias pricing was + applied; the routed tier deployment's real rate must produce spend > 0.""" + key: Final = _key_for(proxy, resources, [zero_priced_alias.alias, zero_priced_alias.tier]) + chat: Final = unwrap(proxy.chat(key, _hello_chat_body(zero_priced_alias.alias))) + assert chat.choices, "chat through the zero-priced alias returned no choices" + rows: Final = proxy.poll_logs_for_key( + key, min_rows=1, predicate=lambda logged: all((row.spend or 0.0) > 0.0 for row in logged) + ) + _assert_served_only_by(rows, CHEAP_SERVED | {zero_priced_alias.tier}, "chat through the zero-priced alias") + priced: Final = tuple((row.model, row.spend) for row in rows) + assert all((row.spend or 0.0) > 0.0 for row in rows), ( + f"expected spend at the tier deployment's own rate, got zero-spend rows: {priced}" + ) + + +class TestComplexityHeuristicScope: + @pytest.mark.covers("reliability.routing.complexity_heuristic.scores_current_ask_only") + def test_trivial_ask_behind_keyword_heavy_system_prompt_stays_on_the_cheap_tier( + self, proxy: ProxyClient, resources: ResourceManager, heuristic_split: HeuristicSplit + ) -> None: + """Pins GitHub PR #36721: the heuristic complexity classifier scores the + caller's current ask alone. The trivial ask scores SIMPLE on its own, + while the accompanying ~2KB agent system prompt is packed with enough + reasoning and complexity keywords that scoring the combined text lands + in REASONING; only ask-only scoring keeps this on the cheap tier.""" + key: Final = _key_for( + proxy, resources, [heuristic_split.alias, heuristic_split.cheap, heuristic_split.strong] + ) + body: Final = ChatBody( + model=heuristic_split.alias, + messages=[ + ChatMessage(role="system", content=KEYWORD_HEAVY_SYSTEM_PROMPT), + ChatMessage(role="user", content=f"hi {unique_marker()}"), + ], + max_tokens=MAX_TOKENS, + ) + chat: Final = unwrap(proxy.chat(key, body)) + assert chat.choices, "chat through the heuristic router returned no choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by( + rows, CHEAP_SERVED | {heuristic_split.cheap}, "trivial ask behind a keyword-heavy system prompt" + ) + + +class TestSemanticAutoRouterResponses: + @pytest.mark.covers("reliability.routing.semantic_auto_router.responses_input_routed") + def test_responses_input_reaches_the_semantic_auto_router( + self, proxy: ProxyClient, resources: ResourceManager, semantic_auto_router: SemanticAutoRouter + ) -> None: + """Pins GitHub PR #37333: /v1/responses input is resolved into messages + for the semantic auto-router's pre-routing hook, so the marker embeds + the input, matches its route, and the target deployment serves the + request; before the fix the hook saw no messages and the request + failed with 400 "Unmapped LLM provider auto_router".""" + key: Final = _key_for( + proxy, + resources, + [semantic_auto_router.marker, semantic_auto_router.target, semantic_auto_router.fallback], + ) + body: Final = ResponsesBody( + model=semantic_auto_router.marker, input=SEMANTIC_ROUTE_UTTERANCE, max_output_tokens=64 + ) + answer: Final = unwrap( + proxy.transport.post( + "/v1/responses", + headers=proxy.transport.bearer(key), + json=body, + response_type=ResponsesApiResponse, + ) + ) + assert answer.id, "/v1/responses through the semantic auto-router returned no response id" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by( + rows, CHEAP_SERVED | {semantic_auto_router.target}, "semantic auto-router /v1/responses string input" + ) From d4db4b1379b943fef632a929f165ed08c2913c15 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:14:05 -0700 Subject: [PATCH 06/30] test(e2e): pin marker alias connection params staying off the routed tier --- tests/e2e/coverage_registry/reliability.yaml | 1 + .../test_auto_router_regressions_e2e.py | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/e2e/coverage_registry/reliability.yaml b/tests/e2e/coverage_registry/reliability.yaml index 5a1d437eac9..b50551ec105 100644 --- a/tests/e2e/coverage_registry/reliability.yaml +++ b/tests/e2e/coverage_registry/reliability.yaml @@ -24,6 +24,7 @@ - {id: reliability.routing.tagged_marker.untagged_tier_deployments_still_served, module: reliability, tier: P1, behavior: routing, variant: tagged_marker, assertions: [untagged_tier_deployments_still_served], exercised_on: [chat_completions, messages], source: "litellm/router_strategy/tag_based_routing.py:433", rationale: "Routing tags the marker consumed no longer constrain deployment selection inside the routed tier group, so untagged tier deployments serve the rewrite (GitHub issue #36621)"} - {id: reliability.routing.tagged_marker.tag_semantics_stay_strict, module: reliability, tier: P1, behavior: routing, variant: tagged_marker, assertions: [tag_semantics_stay_strict], exercised_on: [chat_completions], source: "litellm/router_strategy/tag_based_routing.py:299", rationale: "Tag consumption must not loosen strict semantics: a tagged call aimed straight at an untagged deployment still gets the 401 tags-configuration denial"} - {id: reliability.routing.tagged_marker.responses_input_routes_through_marker, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [responses_input_routes_through_marker], exercised_on: [responses], source: "litellm/router.py:11489", rationale: "Tagged /v1/responses (header or litellm_metadata.tags, string or list input) routes through the marker to its tier, extending the GitHub issues #36620/#36621 tag split to the Responses surface"} +- {id: reliability.routing.tagged_marker.alias_connection_params_stay_with_tier, module: reliability, tier: P0, behavior: routing, variant: tagged_marker, assertions: [alias_connection_params_stay_with_tier], exercised_on: [chat_completions], source: "litellm/router.py:11567", rationale: "An api_key or api_base on the marker alias is never forwarded onto the routed request; the tier deployment calls its provider with its own credential (GitHub PR #36626)"} - {id: reliability.routing.semantic_auto_router.responses_input_routed, module: reliability, tier: P0, behavior: routing, variant: semantic_auto_router, assertions: [responses_input_routed], exercised_on: [responses], source: "litellm/router_strategy/auto_router/auto_router.py:131", fail_before_fix: proven, rationale: "/v1/responses input is resolved into messages for the semantic auto-router pre-routing hook instead of failing 400 Unmapped LLM provider auto_router (GitHub PR #37333)"} - {id: reliability.routing.strategy_alias.custom_pricing_ignored, module: reliability, tier: P1, behavior: routing, variant: strategy_alias, assertions: [custom_pricing_ignored], exercised_on: [chat_completions], source: "litellm/router.py:11489", rationale: "Custom pricing on a strategy-router alias never prices the routed request; spend logs at the routed tier deployment's own rate (GitHub PR #36691)"} - {id: reliability.routing.complexity_heuristic.scores_current_ask_only, module: reliability, tier: P1, behavior: routing, variant: complexity_heuristic, assertions: [scores_current_ask_only], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py:942", rationale: "The heuristic complexity classifier scores the caller's current ask only, so a keyword-heavy agent system prompt cannot inflate the tier (GitHub PR #36721)"} diff --git a/tests/e2e/router/test_auto_router_regressions_e2e.py b/tests/e2e/router/test_auto_router_regressions_e2e.py index ee68d3fa1bb..a23b50f2a73 100644 --- a/tests/e2e/router/test_auto_router_regressions_e2e.py +++ b/tests/e2e/router/test_auto_router_regressions_e2e.py @@ -26,6 +26,9 @@ pins one fixed behavior: request; spend logs at the routed tier deployment's own rate. - GitHub PR #36721: the heuristic complexity classifier scores the caller's current ask only, so a large agent system prompt cannot inflate the tier. +- GitHub PR #36626: connection params on the marker alias (``api_key``, + ``api_base``) stay with the alias; the routed tier calls its provider with + its own credentials. Every deployment is registered via /model/new (stage has no static config for these) and ``enable_tag_filtering`` is flipped through /config/update and @@ -171,6 +174,12 @@ class SemanticAutoRouter: embedding: str +@dataclass(frozen=True, slots=True) +class CredentialedAlias: + alias: str + tier: str + + def _provider_key(env_var: str) -> str: return os.environ.get(env_var) or f"os.environ/{env_var}" @@ -382,6 +391,27 @@ def semantic_auto_router(proxy: ProxyClient) -> Iterator[SemanticAutoRouter]: proxy.delete_model(model_id) +@pytest.fixture(scope="module") +def credentialed_alias(proxy: ProxyClient) -> Iterator[CredentialedAlias]: + marker: Final = unique_marker() + named: Final = CredentialedAlias(alias=f"e2e-cred-alias-{marker}", tier=f"e2e-cred-tier-{marker}") + alias_params: Final = LiteLLMParamsBody( + model="auto_router/complexity_router", + complexity_router_config=_uniform_tier_config(named.tier), + api_key=f"sk-alias-never-used-{marker}", + ) + registrations: Final[tuple[tuple[str, LiteLLMParamsBody], ...]] = ( + (named.tier, LiteLLMParamsBody(model=CHEAP_MODEL, api_key=_provider_key("ANTHROPIC_API_KEY"))), + (named.alias, alias_params), + ) + created: Final = tuple(proxy.create_model(name, params) for name, params in registrations) + try: + yield named + finally: + for model_id in created: + proxy.delete_model(model_id) + + class TestTagSplitRouting: @pytest.mark.covers("reliability.routing.tagged_marker.request_tag_selects_marker") def test_body_tagged_chat_routes_through_the_marker_to_its_tier( @@ -630,3 +660,20 @@ class TestSemanticAutoRouterResponses: _assert_served_only_by( rows, CHEAP_SERVED | {semantic_auto_router.target}, "semantic auto-router /v1/responses string input" ) + + +class TestAliasParamForwarding: + @pytest.mark.covers("reliability.routing.tagged_marker.alias_connection_params_stay_with_tier") + def test_alias_api_key_never_overrides_the_tier_credential( + self, proxy: ProxyClient, resources: ResourceManager, credentialed_alias: CredentialedAlias + ) -> None: + """Pins GitHub PR #36626: an api_key set on the marker alias entry is + never forwarded onto the routed request, so the tier deployment calls + its provider with its own credential. Before the fix the alias's key + was copied into the request, overriding the tier's credential, and + every routed call failed provider auth.""" + key: Final = _key_for(proxy, resources, [credentialed_alias.alias, credentialed_alias.tier]) + chat: Final = unwrap(proxy.chat(key, _hello_chat_body(credentialed_alias.alias))) + assert chat.choices, "chat through the credentialed alias returned no choices" + rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + _assert_served_only_by(rows, CHEAP_SERVED | {credentialed_alias.tier}, "chat through the credentialed alias") From 645b87fae1c79eff67d08ee7033286ddd0ac7fbd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:40:27 +0000 Subject: [PATCH 07/30] fix(types): map nested prompt_tokens_details.cache_creation_input_tokens to cache_write_tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/utils.py | 11 ++++- .../test_dashscope_cost_calculator.py | 41 +++++++++++++++++++ tests/test_litellm/types/test_types_utils.py | 23 +++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index cd2ef9dde2c..b97fc2b3047 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1627,8 +1627,17 @@ class PromptTokensDetailsWrapper( def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) + nested_cache_creation_input_tokens: Final = (self.model_extra or {}).get("cache_creation_input_tokens") self.cache_write_tokens = ( - self.cache_write_tokens if self.cache_write_tokens is not None else self.cache_creation_tokens + self.cache_write_tokens + if self.cache_write_tokens is not None + else ( + self.cache_creation_tokens + if self.cache_creation_tokens is not None + else ( + nested_cache_creation_input_tokens if isinstance(nested_cache_creation_input_tokens, int) else None + ) + ) ) if self.character_count is None: del self.character_count diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py b/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py index 6f5aaabae06..510776ddfdf 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py @@ -271,6 +271,47 @@ class TestDashscopeCostCalculator: assert math.isclose(prompt_cost, expected_prompt_cost, rel_tol=1e-10) + def test_dashscope_nested_cache_creation_input_tokens_bill_at_cache_write_rate(self): + """ + Regression (LIT-5757): DashScope nests cache_creation_input_tokens inside + prompt_tokens_details; those tokens must bill at the tier's cache-creation + rate instead of being folded into text tokens at the input rate. + """ + self._register_tiered_model( + "dashscope/qwen-nested-cache-write-test", + [ + { + "range": [0, 128000], + "input_cost_per_token": 4e-07, + "cache_read_input_token_cost": 1.6e-07, + "cache_creation_input_token_cost": 5e-07, + "output_cost_per_token": 1.6e-06, + } + ], + ) + + usage = Usage( + prompt_tokens=2059, + completion_tokens=201, + total_tokens=2260, + prompt_tokens_details={ + "cached_tokens": 0, + "text_tokens": 2059, + "cache_type": "ephemeral", + "cache_creation_input_tokens": 2048, + "cache_creation": {"ephemeral_5m_input_tokens": 2048}, + }, + completion_tokens_details={"reasoning_tokens": 170}, + ) + + prompt_cost, _ = dashscope_cost_per_token( + model="qwen-nested-cache-write-test", usage=usage + ) + + assert math.isclose( + prompt_cost, (2048 * 5e-07) + (11 * 4e-07), rel_tol=1e-10 + ) + def test_dashscope_tiered_cache_creation_falls_back_to_tier_input_rate(self): """ Tiers without a cache_creation_input_token_cost bill cache-creation tokens at diff --git a/tests/test_litellm/types/test_types_utils.py b/tests/test_litellm/types/test_types_utils.py index cd5e8dda012..52547e064ac 100644 --- a/tests/test_litellm/types/test_types_utils.py +++ b/tests/test_litellm/types/test_types_utils.py @@ -75,6 +75,29 @@ def test_usage_dump(): assert new_usage.prompt_tokens_details.web_search_requests == 1 +def test_prompt_tokens_details_maps_nested_cache_creation_input_tokens(): + """Regression (LIT-5757): DashScope nests the Anthropic-spelled + cache_creation_input_tokens inside prompt_tokens_details. It must populate + the canonical cache_write_tokens/cache_creation_tokens pair, without + overriding an explicitly provided canonical value.""" + from litellm.types.utils import PromptTokensDetailsWrapper + + nested = PromptTokensDetailsWrapper( + cached_tokens=0, text_tokens=2059, cache_creation_input_tokens=2048 + ) + assert nested.cache_write_tokens == 2048 + assert nested.cache_creation_tokens == 2048 + + explicit = PromptTokensDetailsWrapper( + cache_write_tokens=100, cache_creation_input_tokens=2048 + ) + assert explicit.cache_write_tokens == 100 + assert explicit.cache_creation_tokens == 100 + + non_int = PromptTokensDetailsWrapper(cache_creation_input_tokens=None) + assert not hasattr(non_int, "cache_write_tokens") + + def test_usage_server_tool_use_dict_is_coerced_and_round_trips(): from litellm.types.utils import ServerToolUse, Usage From 0585c45cd935954c723eb6d5c2a45c9f346ec7d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:52:02 +0000 Subject: [PATCH 08/30] fix(types): avoid mutable dict literal in nested cache token lookup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index b97fc2b3047..82f60f94656 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1627,7 +1627,10 @@ class PromptTokensDetailsWrapper( def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - nested_cache_creation_input_tokens: Final = (self.model_extra or {}).get("cache_creation_input_tokens") + extra_fields: Final = self.model_extra + nested_cache_creation_input_tokens: Final = ( + extra_fields.get("cache_creation_input_tokens") if extra_fields is not None else None + ) self.cache_write_tokens = ( self.cache_write_tokens if self.cache_write_tokens is not None From 4a43b5080015203785cbca8a0559449ae6bd6b6e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:19:38 +0000 Subject: [PATCH 09/30] test: add Final annotations to LIT-5757 regression test variables Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/types/test_types_utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_litellm/types/test_types_utils.py b/tests/test_litellm/types/test_types_utils.py index 52547e064ac..672aa84cc73 100644 --- a/tests/test_litellm/types/test_types_utils.py +++ b/tests/test_litellm/types/test_types_utils.py @@ -1,5 +1,6 @@ import os import sys +from typing import Final import pytest @@ -82,19 +83,19 @@ def test_prompt_tokens_details_maps_nested_cache_creation_input_tokens(): overriding an explicitly provided canonical value.""" from litellm.types.utils import PromptTokensDetailsWrapper - nested = PromptTokensDetailsWrapper( + nested: Final = PromptTokensDetailsWrapper( cached_tokens=0, text_tokens=2059, cache_creation_input_tokens=2048 ) assert nested.cache_write_tokens == 2048 assert nested.cache_creation_tokens == 2048 - explicit = PromptTokensDetailsWrapper( + explicit: Final = PromptTokensDetailsWrapper( cache_write_tokens=100, cache_creation_input_tokens=2048 ) assert explicit.cache_write_tokens == 100 assert explicit.cache_creation_tokens == 100 - non_int = PromptTokensDetailsWrapper(cache_creation_input_tokens=None) + non_int: Final = PromptTokensDetailsWrapper(cache_creation_input_tokens=None) assert not hasattr(non_int, "cache_write_tokens") From 47f3cf804ed917db460a59605c0af419a6f9c54a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:19:43 -0700 Subject: [PATCH 10/30] fix(router): honor request-level tag filtering in pre-routing strategy selection Key and team router_settings set enable_tag_filtering on the request kwargs, and get_deployments_for_tag already treats that as authoritative, but _select_pre_routing_strategy only consulted the router-wide flag, so tagged auto-router markers still captured untagged requests from keys that enabled filtering. The e2e auto-router module now enables tag filtering through key-level router_settings instead of flipping /config/update module-wide, which was denying concurrently running tagged requests from other suites on the shared per-build CI proxy. --- litellm/router.py | 9 +- tests/e2e/models.py | 13 ++- .../test_auto_router_regressions_e2e.py | 109 ++++-------------- tests/test_litellm/test_router.py | 15 +++ 4 files changed, 52 insertions(+), 94 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 8b9c4b0db1a..85eb2dac51e 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -11454,8 +11454,10 @@ class Router: deployment the strategy was registered from via its (model_name, tags) pair. - With tag filtering enabled, strategies that all carry real tags matching - none of the request's do not capture it when the name also has plain + With tag filtering enabled, router-wide or by the request's + enable_tag_filtering (which the proxy sets from key/team + router_settings), strategies that all carry real tags matching none of + the request's do not capture it when the name also has plain deployments: returning None hands the request to ordinary tag-aware deployment selection. """ @@ -11478,8 +11480,9 @@ class Router: for tagged in candidates: if "default" in tagged.tags: return tagged + request_scoped_filtering: Final = request_kwargs.get("enable_tag_filtering") is True if ( - self.enable_tag_filtering + (self.enable_tag_filtering or request_scoped_filtering) and all(tagged.tags for tagged in candidates) and self._model_name_has_plain_deployments(model) ): diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 957da605546..df5cb841fad 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -73,6 +73,7 @@ class KeyGenerateBody(BaseModel): allowed_passthrough_routes: list[str] | None = None metadata: KeyMetadata | None = None object_permission: ObjectPermission | None = None + router_settings: "RouterSettingsOverride | None" = None class KeyGenerateResponse(BaseModel): @@ -234,16 +235,18 @@ class ChatBody(BaseModel): class RouterSettingsOverride(BaseModel): - """Per-request `router_settings_override` in a /chat/completions body: the - reliability knobs (fallbacks by trigger, retry count) the reliability suite - drives per call instead of via static router config. Serialized exclude_none, so - an override sets only the strategies a test exercises. Each fallbacks map is - model_name -> the ordered fallback model_names to try.""" + """Router settings a test scopes below the global config: sent per request as + `router_settings_override` in a /chat/completions body (the reliability suite's + fallback and retry knobs) or stored on a key as `router_settings` at + /key/generate (the auto-router suite's tag filtering switch). Serialized + exclude_none, so an override sets only the knobs a test exercises. Each + fallbacks map is model_name -> the ordered fallback model_names to try.""" fallbacks: list[dict[str, list[str]]] | None = None context_window_fallbacks: list[dict[str, list[str]]] | None = None content_policy_fallbacks: list[dict[str, list[str]]] | None = None num_retries: int | None = None + enable_tag_filtering: bool | None = None class ReliabilityChatBody(ChatBody): diff --git a/tests/e2e/router/test_auto_router_regressions_e2e.py b/tests/e2e/router/test_auto_router_regressions_e2e.py index a23b50f2a73..c6ef9cda05d 100644 --- a/tests/e2e/router/test_auto_router_regressions_e2e.py +++ b/tests/e2e/router/test_auto_router_regressions_e2e.py @@ -31,15 +31,15 @@ pins one fixed behavior: its own credentials. Every deployment is registered via /model/new (stage has no static config for -these) and ``enable_tag_filtering`` is flipped through /config/update and -restored on teardown, mirroring TestRouterSettings in the management suite. +these) and ``enable_tag_filtering`` is enabled through key-level +``router_settings`` on the keys the tag tests mint, so the switch rides only +this module's own requests and the rest of the suite is never filtered. The served deployment is always read back from the spend log's ``model``, which stores either the registered alias or the provider-prefixed form. """ import json import os -import time from collections.abc import Iterator from dataclasses import dataclass from typing import Final @@ -48,7 +48,7 @@ import pytest from pydantic import BaseModel, ConfigDict, Field from e2e_config import unique_marker -from e2e_http import AnthropicHeaders, AuthHeaders, NoBody, UnauthorizedError, unwrap +from e2e_http import AnthropicHeaders, AuthHeaders, UnauthorizedError, unwrap from lifecycle import ResourceManager from models import ( AnthropicMessagesBody, @@ -58,6 +58,7 @@ from models import ( ChatMetadata, KeyGenerateBody, LiteLLMParamsBody, + RouterSettingsOverride, SpendLogRow, ) from proxy_client import ProxyClient @@ -117,26 +118,6 @@ class ResponsesApiResponse(BaseModel): model: str | None = None -class RouterSettingsPatch(BaseModel): - enable_tag_filtering: bool - - -class ConfigUpdateBody(BaseModel): - router_settings: RouterSettingsPatch - - -class ConfigUpdateResponse(BaseModel): - message: str - - -class RouterCurrentValues(BaseModel): - enable_tag_filtering: bool | None = None - - -class RouterSettingsResponse(BaseModel): - current_values: RouterCurrentValues - - @dataclass(frozen=True, slots=True) class TagSplitDeployments: """Scenario A mirrors the customer-shaped config from GitHub issue #36619: @@ -191,44 +172,16 @@ def _uniform_tier_config(tier_model: str) -> dict[str, object]: } -def _read_tag_filtering(proxy: ProxyClient) -> bool | None: - return unwrap( - proxy.transport.get( - "/router/settings", - headers=proxy.transport.master, - params=NoBody(), - response_type=RouterSettingsResponse, - ) - ).current_values.enable_tag_filtering - - -def _write_tag_filtering(proxy: ProxyClient, enabled: bool) -> None: - response: Final = unwrap( - proxy.transport.post( - "/config/update", - headers=proxy.transport.master, - json=ConfigUpdateBody(router_settings=RouterSettingsPatch(enable_tag_filtering=enabled)), - response_type=ConfigUpdateResponse, +def _key_for( + proxy: ProxyClient, resources: ResourceManager, models: list[str], tag_filtering: bool = False +) -> str: + key: Final = proxy.generate_key( + KeyGenerateBody( + models=models, + user_id="e2e-auto-router-regressions", + router_settings=RouterSettingsOverride(enable_tag_filtering=True) if tag_filtering else None, ) ) - assert "success" in response.message.lower(), ( - f"/config/update reported {response.message!r}, expected a success message" - ) - - -def _await_tag_filtering(proxy: ProxyClient, expected: bool) -> None: - deadline: Final = time.monotonic() + proxy.poll_timeout - while time.monotonic() < deadline: - if _read_tag_filtering(proxy) is expected: - return - time.sleep(proxy.poll_interval) - raise AssertionError( - f"GET /router/settings never reported enable_tag_filtering={expected} after /config/update" - ) - - -def _key_for(proxy: ProxyClient, resources: ResourceManager, models: list[str]) -> str: - key: Final = proxy.generate_key(KeyGenerateBody(models=models, user_id="e2e-auto-router-regressions")) resources.defer(lambda: proxy.delete_key(key)) return key @@ -258,23 +211,7 @@ def _assert_served_only_by(rows: list[SpendLogRow], allowed: frozenset[str], con @pytest.fixture(scope="module") -def tag_filtering(proxy: ProxyClient) -> Iterator[None]: - """enable_tag_filtering is what splits tagged from untagged traffic in every - scenario here. /config/update is the only write path for router_settings; - the original value is restored on teardown so the shared proxy keeps its - configuration for the rest of the run.""" - original: Final = bool(_read_tag_filtering(proxy)) - _write_tag_filtering(proxy, True) - _await_tag_filtering(proxy, True) - try: - yield - finally: - _write_tag_filtering(proxy, original) - _await_tag_filtering(proxy, original) - - -@pytest.fixture(scope="module") -def split(proxy: ProxyClient, tag_filtering: None) -> Iterator[TagSplitDeployments]: +def split(proxy: ProxyClient) -> Iterator[TagSplitDeployments]: marker: Final = unique_marker() deployments: Final = TagSplitDeployments( tag_a=f"e2e-split-a-{marker}", @@ -421,7 +358,7 @@ class TestTagSplitRouting: body metadata tags match the tagged marker under a shared model name is answered by the marker's tier deployment, not by the plain deployment that was registered under the name first.""" - key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True) chat: Final = unwrap(proxy.chat(key, _hello_chat_body(split.shared_a, tags=[split.tag_a]))) assert chat.choices, "tagged chat through the shared name returned no choices" rows: Final = proxy.poll_logs_for_key(key, min_rows=1) @@ -435,7 +372,7 @@ class TestTagSplitRouting: succeed on every call and are all served by the plain deployment; the tagged marker never captures them, so no intermittent auto-router errors and no tier hijacking.""" - key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True) for _ in range(5): chat = unwrap(proxy.chat(key, _hello_chat_body(split.shared_a))) assert chat.choices, "untagged chat through the shared name returned no choices" @@ -449,7 +386,7 @@ class TestTagSplitRouting: """Pins GitHub issue #36620 on the /v1/messages surface: an untagged Anthropic-native request to the shared name is served by the plain deployment, not captured by the tagged marker.""" - key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True) answer: Final = unwrap(proxy.messages(key, _hello_messages_body(split.shared_a))) assert answer.content or answer.choices, "untagged /v1/messages returned neither content nor choices" rows: Final = proxy.poll_logs_for_key(key, min_rows=1) @@ -465,7 +402,7 @@ class TestUntaggedTierDeployments: x-litellm-tags header selects the tagged marker, and the rewrite still lands on the tier deployment even though that deployment carries no tags, because the marker consumed the routing tags.""" - key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b]) + key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b], tag_filtering=True) headers: Final = TaggedAnthropicHeaders(authorization=f"Bearer {key}", x_litellm_tags=split.tag_b) answer: Final = unwrap( proxy.transport.post( @@ -487,7 +424,7 @@ class TestUntaggedTierDeployments: tagged marker rewrites the request to its tier model, the consumed routing tags no longer constrain deployment selection, so the untagged tier deployment serves the request instead of a strict-tag denial.""" - key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b]) + key: Final = _key_for(proxy, resources, [split.shared_b, split.tier_b], tag_filtering=True) chat: Final = unwrap(proxy.chat(key, _hello_chat_body(split.shared_b, tags=[split.tag_b]))) assert chat.choices, "body-tagged chat through the marker-first shared name returned no choices" rows: Final = proxy.poll_logs_for_key(key, min_rows=1) @@ -500,7 +437,7 @@ class TestUntaggedTierDeployments: """The tag-consumption fix must not loosen strict tag semantics: a tagged request aimed directly at an untagged deployment (no marker involved) is still rejected with the 401 tags-configuration error.""" - key: Final = _key_for(proxy, resources, [split.tier_b]) + key: Final = _key_for(proxy, resources, [split.tier_b], tag_filtering=True) result: Final = proxy.chat(key, _hello_chat_body(split.tier_b, tags=[split.tag_b])) assert isinstance(result, UnauthorizedError), ( f"expected the tagged direct call to an untagged deployment to be denied with 401, got {result}" @@ -516,7 +453,7 @@ class TestResponsesApiTagRouting: #36620/#36621): a /v1/responses request with string input, tagged via the x-litellm-tags header, succeeds and routes through the tagged marker to its tier.""" - key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True) headers: Final = TaggedAuthHeaders(authorization=f"Bearer {key}", x_litellm_tags=split.tag_a) body: Final = ResponsesBody( model=split.shared_a, input=f"say hello {unique_marker()}", max_output_tokens=64 @@ -535,7 +472,7 @@ class TestResponsesApiTagRouting: """Pins the body-tag and list-input combination of the same split: /v1/responses with litellm_metadata.tags and structured input items routes through the tagged marker to its tier.""" - key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True) body: Final = ResponsesBody( model=split.shared_a, input=[ResponsesInputItem(role="user", content=f"say hello {unique_marker()}")], @@ -561,7 +498,7 @@ class TestResponsesApiTagRouting: """Pins the untagged half of the /v1/responses tag split: an untagged request to the shared name is served by the plain deployment, matching the chat and messages surfaces.""" - key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a]) + key: Final = _key_for(proxy, resources, [split.shared_a, split.tier_a], tag_filtering=True) body: Final = ResponsesBody( model=split.shared_a, input=f"say hello {unique_marker()}", max_output_tokens=64 ) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index b3c348a1221..16a309ebb4c 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -7674,6 +7674,21 @@ class TestTaggedAutoRouterOnSharedModelName: assert response is not None assert response.model == "gemini-flash" + @pytest.mark.asyncio + async def test_request_level_tag_filtering_from_key_settings_bypasses_the_marker(self): + router = self._router(marker_tags=["route"], include_plain_sibling=True, enable_tag_filtering=False) + + assert await self._hook_response(router, {"enable_tag_filtering": True}) is None + + @pytest.mark.asyncio + async def test_globally_disabled_filtering_still_lets_the_marker_capture_untagged_requests(self): + router = self._router(marker_tags=["route"], include_plain_sibling=True, enable_tag_filtering=False) + + response = await self._hook_response(router, {}) + + assert response is not None + assert response.model == "gemini-flash" + @pytest.mark.asyncio async def test_marker_only_alias_still_captures_untagged_requests(self): router = self._router(marker_tags=["route"], include_plain_sibling=False, enable_tag_filtering=True) From 490079e7df8c52470a89aa69685a5d10f81a53e8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:38:30 -0700 Subject: [PATCH 11/30] test: cover nested cache_creation_input_tokens in responses bridge and spend logs --- .../test_spend_tracking_utils.py | 24 ++++++++++++++++ .../test_responses_api_bridge_non_stream.py | 28 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 33d835652cd..e5add059260 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -124,6 +124,30 @@ def test_get_logging_payload_maps_openai_cache_write_tokens_to_cache_creation_in assert additional_usage_values["prompt_tokens_details"]["cache_write_tokens"] == 800 +def test_get_logging_payload_maps_nested_cache_creation_input_tokens(): + """ + Regression (LIT-5757): DashScope nests cache_creation_input_tokens inside + prompt_tokens_details; SpendLogs must record it as cache_creation_input_tokens. + """ + additional_usage_values: Final = _get_additional_usage_values_for_usage( + litellm.Usage( + prompt_tokens=2059, + completion_tokens=31, + total_tokens=2090, + prompt_tokens_details={ + "cached_tokens": 0, + "text_tokens": 2059, + "cache_type": "ephemeral", + "cache_creation_input_tokens": 2048, + "cache_creation": {"ephemeral_5m_input_tokens": 2048}, + }, + ) + ) + + assert additional_usage_values["cache_creation_input_tokens"] == 2048 + assert additional_usage_values["prompt_tokens_details"]["cache_write_tokens"] == 2048 + + def test_get_logging_payload_preserves_anthropic_cache_creation_input_tokens(): additional_usage_values = _get_additional_usage_values_for_usage( litellm.Usage( diff --git a/tests/test_litellm/test_responses_api_bridge_non_stream.py b/tests/test_litellm/test_responses_api_bridge_non_stream.py index 25a3bc2dbba..c272b151865 100644 --- a/tests/test_litellm/test_responses_api_bridge_non_stream.py +++ b/tests/test_litellm/test_responses_api_bridge_non_stream.py @@ -1,6 +1,6 @@ import os import sys -from typing import Optional +from typing import Final, Optional from unittest.mock import Mock import pytest @@ -192,6 +192,32 @@ def test_transform_usage_with_cached_tokens_only(): print("✓ Transformation works with cached_tokens only") +def test_transform_usage_maps_nested_cache_creation_input_tokens(): + """ + Regression (LIT-5757): DashScope nests cache_creation_input_tokens inside + prompt_tokens_details; the bridge must surface it as cache_write_tokens. + """ + usage: Final = Usage( + prompt_tokens=2059, + completion_tokens=31, + total_tokens=2090, + prompt_tokens_details={ + "cached_tokens": 0, + "text_tokens": 2059, + "cache_type": "ephemeral", + "cache_creation_input_tokens": 2048, + "cache_creation": {"ephemeral_5m_input_tokens": 2048}, + }, + ) + + responses_usage: Final = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage( + usage + ) + + assert responses_usage.input_tokens_details is not None + assert responses_usage.input_tokens_details.cache_write_tokens == 2048 + + def test_transform_usage_with_reasoning_tokens_only(): """ Test transformation when only reasoning_tokens is provided (no cached_tokens). From 5012d11a18a0cef17cb0a2be286604a4a342fa7f Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 18:52:41 -0700 Subject: [PATCH 12/30] refactor(ui): style the logging settings from semantic tokens (#37385) team/LoggingSettings.tsx carried 34 hardcoded palette classes and common_components/PremiumLoggingSettings.tsx another 9, so both render light-only regardless of theme. Map the neutrals onto foreground, muted-foreground, muted and border, the red affordances onto destructive, and swap the hand-rolled chips for the shadcn Badge primitive. The three event-type options carried decorative green, red and blue dots. The design system has no success or info token, so the dots are dropped and the option labels, which already say "Success Only", "Failure Only" and "Success & Failure", carry the meaning on their own. This is groundwork, not a visible change: nothing in the dashboard ever applies the .dark class today, so the dark palette is unreachable. The files no longer hardcode colour and will follow the theme once one exists. --- .../PremiumLoggingSettings.test.tsx | 39 ++++++++ .../PremiumLoggingSettings.tsx | 13 +-- .../components/team/LoggingSettings.test.tsx | 37 ++++++++ .../src/components/team/LoggingSettings.tsx | 88 +++++++------------ 4 files changed, 115 insertions(+), 62 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.test.tsx diff --git a/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.test.tsx b/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.test.tsx new file mode 100644 index 00000000000..76b90bdca90 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.test.tsx @@ -0,0 +1,39 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { renderWithProviders, screen } from "../../../tests/test-utils"; +import PremiumLoggingSettings from "./PremiumLoggingSettings"; + +const SOURCE_PATH = resolve(process.cwd(), "src/components/common_components/PremiumLoggingSettings.tsx"); + +const HARDCODED_PALETTE = + /\b(?:text|bg|border|hover:bg|hover:text|hover:border|dark:bg|dark:text|dark:border|ring|divide|fill|stroke)-(?:gray|slate|zinc|neutral|stone|red|blue|green|yellow|amber|orange|indigo|purple|pink|rose|teal|cyan|sky|violet|fuchsia|lime|emerald)-\d+(?:\/\d+)?\b/g; + +const SEMANTIC_TOKEN = + /\b(?:text|bg|border|hover:bg|hover:text|ring|divide|fill|stroke)-(?:foreground|muted-foreground|muted|background|card|popover|primary|secondary|destructive|border|input|accent|ring)(?:-foreground)?(?:\/\d+)?\b/g; + +describe("PremiumLoggingSettings", () => { + it("styles itself from semantic tokens instead of hardcoded palette classes", () => { + const source = readFileSync(SOURCE_PATH, "utf8"); + + expect(source).toContain("export function PremiumLoggingSettings"); + expect(source.match(SEMANTIC_TOKEN) ?? []).not.toHaveLength(0); + expect(source.match(HARDCODED_PALETTE) ?? []).toHaveLength(0); + }); + + it("shows the enterprise notice and withholds the editor from a free user", () => { + renderWithProviders(); + + expect(screen.getByText(/LiteLLM Enterprise feature/)).toBeInTheDocument(); + expect(screen.getByText("✨ langfuse-logging")).toBeInTheDocument(); + expect(screen.queryByText("Logging Integrations")).not.toBeInTheDocument(); + }); + + it("renders the editor for a premium user", () => { + renderWithProviders(); + + expect(screen.getByText("Logging Integrations")).toBeInTheDocument(); + expect(screen.queryByText(/LiteLLM Enterprise feature/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.tsx b/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.tsx index 62d2f182120..d1f3cfe6712 100644 --- a/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PremiumLoggingSettings.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Badge } from "@/components/ui/badge"; import LoggingSettings from "../team/LoggingSettings"; interface PremiumLoggingSettingsProps { @@ -20,15 +21,15 @@ export function PremiumLoggingSettings({ return (
-
+ ✨ langfuse-logging -
-
+ + ✨ datadog-logging -
+
-
-

+

+

Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for all free users. Get a trial key{" "} diff --git a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx index 17f20ad0dcc..ae02a26ddb0 100644 --- a/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/team/LoggingSettings.test.tsx @@ -1,9 +1,19 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, fireEvent } from "../../../tests/test-utils"; import LoggingSettings from "./LoggingSettings"; +const SOURCE_PATH = resolve(process.cwd(), "src/components/team/LoggingSettings.tsx"); + +const HARDCODED_PALETTE = + /\b(?:text|bg|border|hover:bg|hover:text|hover:border|dark:bg|dark:text|dark:border|ring|divide|fill|stroke)-(?:gray|slate|zinc|neutral|stone|red|blue|green|yellow|amber|orange|indigo|purple|pink|rose|teal|cyan|sky|violet|fuchsia|lime|emerald)-\d+(?:\/\d+)?\b/g; + +const SEMANTIC_TOKEN = + /\b(?:text|bg|border|hover:bg|hover:text|ring|divide|fill|stroke)-(?:foreground|muted-foreground|muted|background|card|popover|primary|secondary|destructive|border|input|accent|ring)(?:-foreground)?(?:\/\d+)?\b/g; + describe("LoggingSettings", () => { beforeEach(() => { vi.clearAllMocks(); @@ -163,6 +173,33 @@ describe("LoggingSettings", () => { expect(screen.getByText("C")).toBeInTheDocument(); }); + it("styles itself from semantic tokens instead of hardcoded palette classes", () => { + const source = readFileSync(SOURCE_PATH, "utf8"); + + expect(source).toContain("const LoggingSettings"); + expect(source.match(SEMANTIC_TOKEN) ?? []).not.toHaveLength(0); + expect(source.match(HARDCODED_PALETTE) ?? []).toHaveLength(0); + }); + + it("reports the chosen event type when a different option is picked", async () => { + const user = userEvent.setup({ delay: null }); + const mockOnChange = vi.fn(); + const initialValue = [ + { + callback_name: "langsmith", + callback_type: "success", + callback_vars: {}, + }, + ]; + + renderWithProviders(); + + await user.click(screen.getByTitle("Success Only")); + await user.click(await screen.findByTitle("Failure Only")); + + expect(mockOnChange).toHaveBeenCalledWith([expect.objectContaining({ callback_type: "failure" })]); + }); + it("correctly handles numerical input with decimal values", () => { const mockOnChange = vi.fn(); diff --git a/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx b/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx index eee6e76569d..0fc936159fa 100644 --- a/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Select, Tooltip, Divider } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; @@ -142,30 +143,24 @@ const LoggingSettings: React.FC = ({ if (Object.keys(dynamicParams).length === 0) return null; return ( -

+
-
-
+
+
- Integration Parameters + Integration Parameters
{Object.entries(dynamicParams).map(([paramName, paramType]) => (
-