fix: resolve Python 3.14 OCR annotations and remaining matrix failures

This commit is contained in:
Yujong Lee 2026-09-02 14:35:38 -07:00
parent 729ec4c7b8
commit cfcaaa03d6
15 changed files with 134 additions and 76 deletions

View file

@ -45,7 +45,7 @@
"limit": 25
},
"reportInvalidTypeForm": {
"limit": 34
"limit": 32
},
"reportInvalidTypeVarUse": {
"limit": 2
@ -105,13 +105,13 @@
"limit": 109
},
"reportUnknownMemberType": {
"limit": 38350
"limit": 38348
},
"reportUnknownParameterType": {
"limit": 19626
"limit": 19624
},
"reportUnknownVariableType": {
"limit": 29890
"limit": 29889
},
"reportUnnecessaryCast": {
"limit": 111

View file

@ -2,6 +2,7 @@
Base OCR transformation configuration.
"""
import builtins
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final, Literal
@ -93,8 +94,8 @@ class OCRResponse(LiteLLMPydanticObjectBase):
document_annotation: Any | None = None
usage_info: OCRUsageInfo | None = None
content: str | None = None
tables: list[dict[str, object]] | None = None
keyValuePairs: list[dict[str, object]] | None = None
tables: list[dict[str, builtins.object]] | None = None
keyValuePairs: list[dict[str, builtins.object]] | None = None
object: str = "ocr"
model_config = {"extra": "allow"}
@ -102,11 +103,11 @@ class OCRResponse(LiteLLMPydanticObjectBase):
# Define private attributes using PrivateAttr
_hidden_params: dict = PrivateAttr(default_factory=dict)
def set_provider_native_response(self, native_response: Mapping[str, object]) -> None:
def set_provider_native_response(self, native_response: Mapping[str, builtins.object]) -> None:
"""Keep the provider's own response payload alongside the normalized one."""
self._hidden_params[PROVIDER_NATIVE_RESPONSE_KEY] = native_response
def get_provider_native_response(self) -> Mapping[str, object] | None:
def get_provider_native_response(self) -> Mapping[str, builtins.object] | None:
"""The provider's own response payload, when `req_format=native` was requested."""
native_response: Final = self._hidden_params.get(PROVIDER_NATIVE_RESPONSE_KEY)
return native_response if isinstance(native_response, dict) else None

View file

@ -21,6 +21,6 @@
"limit": 117
},
"TQ008": {
"limit": 11103
"limit": 11069
}
}

View file

@ -100,6 +100,8 @@ def test_proxy_reducto_ocr_json_passthrough_data_uri(client_no_auth):
pages=[OCRPage(index=0, markdown="Proxy OCR")],
model="parse-v3",
usage_info=OCRUsageInfo(pages_processed=1, credits=1),
tables=[{"cells": [["Total", 42]], "page": 1}],
keyValuePairs=[{"key": "approved", "value": True, "confidence": 0.9}],
)
data_uri = "data:application/pdf;base64,JVBERi0xLjQK"
@ -135,3 +137,5 @@ def test_proxy_reducto_ocr_json_passthrough_data_uri(client_no_auth):
assert response_body["object"] == "ocr"
assert response_body["usage_info"]["credits"] == 1
assert response_body["pages"][0]["markdown"] == "Proxy OCR"
assert response_body["tables"] == [{"cells": [["Total", 42]], "page": 1}]
assert response_body["keyValuePairs"] == [{"key": "approved", "value": True, "confidence": 0.9}]

View file

@ -1,3 +1,4 @@
from importlib import import_module
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
@ -13,15 +14,12 @@ def mock_gcs_dependencies():
mock_async_client = AsyncMock()
with (
patch(
"litellm.caching.gcs_cache._get_httpx_client", return_value=mock_sync_client
patch.object(import_module("litellm.caching.gcs_cache"), "_get_httpx_client", return_value=mock_sync_client
),
patch(
"litellm.caching.gcs_cache.get_async_httpx_client",
patch.object(import_module("litellm.caching.gcs_cache"), "get_async_httpx_client",
return_value=mock_async_client,
),
patch(
"litellm.caching.gcs_cache.GCSBucketBase.sync_construct_request_headers",
patch.object(import_module("litellm.caching.gcs_cache").GCSBucketBase, "sync_construct_request_headers",
return_value={},
),
):

View file

@ -1,3 +1,4 @@
from importlib import import_module
import json
from unittest.mock import MagicMock, patch
@ -64,7 +65,7 @@ async def test_redis_cluster_async_batch_get(mock_init_redis_cluster):
@patch("litellm._redis.get_redis_connection_pool")
@patch("litellm._redis.get_redis_client")
@patch("litellm.caching.redis_cache.RedisCache._setup_health_pings")
@patch.object(import_module("litellm.caching.redis_cache").RedisCache, "_setup_health_pings")
def test_cache_init_creates_cluster_cache_from_env_var(
mock_health, mock_get_client, mock_get_pool, monkeypatch
):
@ -91,7 +92,7 @@ def test_cache_init_creates_cluster_cache_from_env_var(
@patch("litellm._redis.get_redis_connection_pool")
@patch("litellm._redis.get_redis_client")
@patch("litellm.caching.redis_cache.RedisCache._setup_health_pings")
@patch.object(import_module("litellm.caching.redis_cache").RedisCache, "_setup_health_pings")
def test_cache_init_creates_redis_cache_without_cluster_config(
mock_health, mock_get_client, mock_get_pool, monkeypatch
):

View file

@ -1,3 +1,4 @@
from importlib import import_module
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -92,7 +93,7 @@ def _make_redis_cache():
patches = [
patch("litellm._redis.get_redis_client", return_value=mock_sync_client),
patch("litellm._redis.get_redis_connection_pool", return_value=mock_async_pool),
patch("litellm.caching.redis_cache.RedisCache._setup_health_pings"),
patch.object(import_module("litellm.caching.redis_cache").RedisCache, "_setup_health_pings"),
]
for p in patches:
p.start()

View file

@ -1,3 +1,4 @@
from importlib import import_module
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -1453,7 +1454,7 @@ def test_cache_forwards_semantic_cache_embedding_timeout():
from litellm.caching.caching import Cache
from litellm.types.caching import LiteLLMCacheType
with patch("litellm.caching.caching.RedisSemanticCache") as backend:
with patch.object(import_module("litellm.caching.caching"), "RedisSemanticCache") as backend:
Cache(
type=LiteLLMCacheType.REDIS_SEMANTIC,
similarity_threshold=0.8,

View file

@ -1,11 +1,16 @@
"""Unit tests for MCP OAuth passthrough tool-fetch behavior."""
import sys
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
if sys.version_info < (3, 11):
from exceptiongroup import ExceptionGroup
from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthError
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
@ -37,7 +42,7 @@ def test_extract_upstream_auth_failure_walks_exception_group():
inner = httpx.HTTPStatusError("401", request=response.request, response=response)
try:
raise ExceptionGroup("wrapped", [inner]) # noqa: F821 (PEP 654, py3.11+)
raise ExceptionGroup("wrapped", [inner])
except Exception as group:
result = _extract_upstream_auth_failure(group)

View file

@ -18,6 +18,12 @@ if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
from mcp.types import Tool as MCPTool
requires_semantic_router = pytest.mark.skipif(
sys.version_info >= (3, 14), reason="The semantic-router extra excludes Python 3.14"
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_basic_filtering():
"""
@ -145,6 +151,7 @@ async def test_semantic_filter_basic_filtering():
print(f" Filter respects top_k parameter correctly")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_top_k_limiting():
"""
@ -328,6 +335,7 @@ async def test_semantic_filter_extract_user_query():
assert query3 == ""
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_triggers_on_completion():
"""
@ -453,6 +461,7 @@ async def test_semantic_filter_hook_skips_no_tools():
print("✅ Hook correctly skips requests without tools")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_preserves_native_tools():
"""
@ -584,6 +593,7 @@ async def test_semantic_filter_hook_preserves_native_tools():
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_all_native_tools():
"""
@ -684,6 +694,7 @@ async def test_semantic_filter_hook_all_native_tools():
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_responses_api_name_collision():
"""
@ -774,6 +785,7 @@ async def test_semantic_filter_hook_responses_api_name_collision():
print("✅ Responses API tool with MCP-matching name correctly classified as native")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_filters_expanded_litellm_proxy_tools():
"""
@ -889,6 +901,7 @@ async def test_semantic_filter_hook_filters_expanded_litellm_proxy_tools():
print(f"✅ Expanded litellm_proxy tools filtered: {len(expanded_tools)} -> {len(allowed_tools)}, stats={stats}")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_narrows_mcp_reference_for_chat_completions():
"""
@ -1008,6 +1021,7 @@ async def test_semantic_filter_hook_narrows_mcp_reference_for_chat_completions()
print(f"✅ chat completions: MCP reference preserved, narrowed to {allowed_tools}")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_zero_matches_exposes_all_tools_on_both_paths():
"""
@ -1126,6 +1140,7 @@ async def test_semantic_filter_hook_zero_matches_exposes_all_tools_on_both_paths
print("✅ zero matches: both the MCP reference path and the plain tool path expose every tool")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_filters_expanded_tools_with_string_input():
"""
@ -1266,6 +1281,7 @@ async def test_semantic_filter_hook_expansion_skips_filter_when_disabled():
print("✅ Disabled filter: MCP reference untouched, no spurious stats")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_preserves_tool_order():
"""
@ -1651,6 +1667,7 @@ def _make_context_window_filter(state, top_k: int = 3):
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_fails_closed_on_query_time_context_window_error():
"""
@ -1682,6 +1699,7 @@ async def test_semantic_filter_fails_closed_on_query_time_context_window_error()
print("✅ Query-time context window overflow fails closed")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_records_build_time_context_window_error():
"""
@ -1715,6 +1733,7 @@ async def test_semantic_filter_records_build_time_context_window_error():
print("✅ Build-time context window overflow is recorded and fails closed")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_fails_closed_on_context_window_error():
"""
@ -1762,6 +1781,7 @@ async def test_semantic_filter_hook_fails_closed_on_context_window_error():
print("✅ Hook fails closed with actionable 400 on context window overflow")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_window_error():
"""
@ -1828,6 +1848,7 @@ async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_windo
print("✅ Expansion path fails closed with actionable 400 on context window overflow")
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools():
"""
@ -2018,6 +2039,7 @@ def _weather_tool():
)
@requires_semantic_router
@pytest.mark.asyncio
async def test_filter_indexes_request_tools_when_startup_index_is_empty():
"""
@ -2042,6 +2064,7 @@ async def test_filter_indexes_request_tools_when_startup_index_is_empty():
print("✅ Empty startup index is built from authed request-time tools")
@requires_semantic_router
@pytest.mark.asyncio
async def test_filter_indexes_tools_missing_from_partial_index():
"""
@ -2070,6 +2093,7 @@ async def test_filter_indexes_tools_missing_from_partial_index():
print("✅ Partial startup index is completed from request-time tools, embedding each tool once")
@requires_semantic_router
@pytest.mark.asyncio
async def test_filter_fails_open_when_matches_are_not_in_available_tools():
"""
@ -2093,6 +2117,7 @@ async def test_filter_fails_open_when_matches_are_not_in_available_tools():
print("✅ Matches outside available_tools fail open instead of dropping every tool")
@requires_semantic_router
@pytest.mark.asyncio
async def test_request_time_context_window_error_is_request_scoped():
"""
@ -2129,6 +2154,7 @@ async def test_request_time_context_window_error_is_request_scoped():
print("✅ Request-time context window overflow is scoped to the request, not the worker")
@requires_semantic_router
@pytest.mark.asyncio
async def test_foreign_index_routes_cannot_displace_available_tools():
"""

View file

@ -6,6 +6,7 @@ Includes file_search emulation: the flag must be forwarded on inner aresponses
calls so routed requests do not hit a custom api_base /v1/responses endpoint.
"""
from importlib import import_module
from unittest.mock import MagicMock, patch
@ -17,11 +18,11 @@ from litellm.types.utils import Choices, Message, ModelResponse, Usage
class TestUseResponsesApiBridgeFlag:
"""Test that bridge opt-in forces the chat completions path."""
@patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler"
@patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
)
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
def test_bridge_used_when_use_chat_completions_api_true(
self, mock_get_config, mock_bridge_handler
@ -39,11 +40,11 @@ class TestUseResponsesApiBridgeFlag:
mock_bridge_handler.assert_called_once()
@patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler"
@patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
)
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
def test_bridge_used_when_model_uses_chat_completions_prefix(
self, mock_get_config, mock_bridge_handler
@ -62,9 +63,9 @@ class TestUseResponsesApiBridgeFlag:
# Model string is provider-normalized after resolution; prefix only forces the bridge.
assert mock_bridge_handler.call_args.kwargs["model"].endswith("my-custom-model")
@patch("litellm.responses.main.base_llm_http_handler.response_api_handler")
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler")
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
def test_native_forwarding_when_flag_absent(
self, mock_get_config, mock_native_handler
@ -82,11 +83,11 @@ class TestUseResponsesApiBridgeFlag:
mock_native_handler.assert_called_once()
@patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler"
@patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
)
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler):
"""use_chat_completions_api should be popped and not passed to the bridge handler."""
@ -104,11 +105,11 @@ class TestUseResponsesApiBridgeFlag:
all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {}
assert "use_chat_completions_api" not in all_kwargs
@patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler"
@patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
)
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
def test_bridge_used_when_provider_config_none(
self, mock_get_config, mock_bridge_handler
@ -127,8 +128,8 @@ class TestUseResponsesApiBridgeFlag:
mock_bridge_handler.assert_called_once()
@patch("litellm.acompletion")
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
async def test_allowed_openai_params_forwarded_through_bridge(
self, mock_get_config, mock_acompletion
@ -164,9 +165,9 @@ class TestUseResponsesApiBridgeFlag:
"reasoning_effort"
]
@patch("litellm.responses.file_search.emulated_handler._call_aresponses")
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(import_module("litellm.responses.file_search.emulated_handler"), "_call_aresponses")
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
async def test_bridge_flag_forwarded_to_file_search_emulation(
self, mock_get_config, mock_call_aresponses
@ -206,12 +207,12 @@ class TestUseResponsesApiBridgeFlag:
call_kwargs.get("use_chat_completions_api") is True
), "use_chat_completions_api should be forwarded to inner aresponses call"
@patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler"
@patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
)
@patch("litellm.vector_stores.main.asearch")
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
async def test_bridge_flag_prevents_native_responses_endpoint_call(
self, mock_get_config, mock_asearch, mock_bridge_handler
@ -280,10 +281,10 @@ class TestUseResponsesApiBridgeFlag:
assert result is not None
assert result.id is not None
@patch("litellm.responses.main.base_llm_http_handler.response_api_handler")
@patch.object(import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler")
@patch("litellm.vector_stores.main.asearch")
@patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config"
@patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config"
)
async def test_without_bridge_flag_uses_native_endpoint(
self, mock_get_config, mock_asearch, mock_native_handler

View file

@ -13,6 +13,7 @@ Covers:
I) async path propagates optional params to downstream handler
"""
from importlib import import_module
import asyncio
from typing import List, cast
from unittest.mock import AsyncMock, MagicMock, patch
@ -62,23 +63,20 @@ def _provider_by_model(model: str, **_: object) -> tuple[str, str, None, None]:
def _patch_responses_dispatch():
"""Patch everything after the prompt management block so tests stay unit-level."""
return [
patch(
"litellm.responses.main.litellm.get_llm_provider",
patch.object(
import_module("litellm.responses.main").litellm, "get_llm_provider",
side_effect=_provider_by_model,
),
patch(
"litellm.responses.mcp.litellm_proxy_mcp_handler."
"LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway",
patch.object(
import_module("litellm.responses.mcp.litellm_proxy_mcp_handler").LiteLLM_Proxy_MCP_Handler, "_should_use_litellm_mcp_gateway",
return_value=False,
),
patch(
"litellm.responses.main.ProviderConfigManager"
".get_provider_responses_api_config",
patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config",
return_value=None,
),
patch(
"litellm.responses.main.litellm_completion_transformation_handler"
".response_api_handler",
patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler",
return_value=MagicMock(),
),
]
@ -393,8 +391,8 @@ class TestResponsesAPIPromptManagement:
patches = _patch_responses_dispatch()
with (
patch(
"litellm.responses.main.litellm.get_llm_provider",
patch.object(
import_module("litellm.responses.main").litellm, "get_llm_provider",
side_effect=_provider_by_model,
),
patches[1],
@ -599,8 +597,8 @@ def test_sync_prompt_swap_resolves_credentials_for_swapped_provider(monkeypatch:
monkeypatch.setenv("XAI_API_KEY", "sk-xai-test")
logging_obj = _make_logging_obj("gpt-4o-mini", [{"role": "user", "content": "hi"}])
with patch( # test-quality-ok: handler boundary stub proves creds resolve for the swapped provider without network
"litellm.responses.main.base_llm_http_handler.response_api_handler", return_value=MagicMock()
with patch.object( # test-quality-ok: handler boundary stub proves creds resolve for the swapped provider without network
import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler", return_value=MagicMock()
) as mock_handler:
litellm.responses(input="hi", model="xai/grok-4", prompt_id="p1", litellm_logging_obj=logging_obj)

View file

@ -1,3 +1,4 @@
from importlib import import_module
import base64
from unittest.mock import MagicMock, patch
@ -580,12 +581,12 @@ def test_responses_extra_body_forwarded_to_completion_transformation_handler():
so it was silently dropped.
"""
with (
patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config",
patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config",
return_value=None,
),
patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler",
patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler",
) as mock_handler,
):
mock_handler.return_value = MagicMock()
@ -611,12 +612,12 @@ def test_responses_maps_reasoning_effort_from_litellm_params_to_reasoning():
that cannot set extra_body.
"""
with (
patch(
"litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config",
patch.object(
import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config",
return_value=None,
),
patch(
"litellm.responses.main.litellm_completion_transformation_handler.response_api_handler",
patch.object(
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler",
) as mock_handler,
):
mock_handler.return_value = MagicMock()

View file

@ -1,3 +1,4 @@
from importlib import import_module
import json
import pytest
@ -148,8 +149,8 @@ class TestTextFormatConversion:
incomplete_details=None,
)
with patch(
"litellm.responses.main.base_llm_http_handler.response_api_handler",
with patch.object(
import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler",
new=mock_handler,
):
litellm._turn_on_debug()

View file

@ -6,6 +6,7 @@ Tests the rule-based complexity scoring and tier assignment logic.
import asyncio
import logging
import sys
from typing import Dict, List
from unittest.mock import AsyncMock, MagicMock, patch
@ -46,6 +47,11 @@ from litellm.types.router import (
)
requires_semantic_router = pytest.mark.skipif(
sys.version_info >= (3, 14), reason="The semantic-router extra excludes Python 3.14"
)
@pytest.fixture
def mock_router_instance():
"""Create a mock LiteLLM Router instance."""
@ -3413,6 +3419,7 @@ class FakeEmbeddingRouter:
class TestSemanticKeywordTierRules:
"""Test embedding-based keyword_tier_rules matching."""
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_match_routes_to_rule_tier(self, basic_config):
"""A paraphrase (no literal keyword) still routes via embedding similarity."""
@ -3441,6 +3448,7 @@ class TestSemanticKeywordTierRules:
assert result.model == "o1-preview" # REASONING via semantic match
assert fake_router.async_embedding_calls, "expected an embedding call for the prompt"
@requires_semantic_router
@pytest.mark.asyncio
async def test_tier_matches_on_best_utterance_not_diluted_by_others(self, basic_config):
"""A tier with several keywords must match if the query is close to ANY of them,
@ -3475,6 +3483,7 @@ class TestSemanticKeywordTierRules:
assert result is not None
assert result.model == "o1-preview" # REASONING via best-utterance semantic match
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_embedding_call_carries_caller_metadata(self, basic_config):
"""The query embedding call must carry the caller's metadata/litellm_metadata
@ -3507,6 +3516,7 @@ class TestSemanticKeywordTierRules:
assert fake_router.async_embedding_kwargs[0]["metadata"] == {**caller_metadata, **origin}
assert fake_router.async_embedding_kwargs[0]["litellm_metadata"] == {**caller_litellm_metadata, **origin}
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_embedding_call_captures_request_body_in_proxy_server_request(self, basic_config):
"""The query embedding call must supply proxy_server_request so its request is logged.
@ -3540,6 +3550,7 @@ class TestSemanticKeywordTierRules:
assert body["model"] == "fake-embed"
assert body["input"] == ["roll out my k8s cluster"]
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_embedding_call_propagates_turn_off_message_logging(self, basic_config):
"""A caller's turn_off_message_logging must reach the query embedding call.
@ -3570,6 +3581,7 @@ class TestSemanticKeywordTierRules:
assert fake_router.async_embedding_kwargs, "expected an embedding call for the prompt"
assert fake_router.async_embedding_kwargs[0]["turn_off_message_logging"] is True
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_embedding_call_strips_budget_reservation(self, basic_config):
"""The embedding call must not carry the parent request's budget reservation.
@ -3623,6 +3635,7 @@ class TestSemanticKeywordTierRules:
"budget_reservation": {"reserved_cost": 1.0},
}
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_routelayer_build_runs_off_event_loop(self, basic_config):
"""Building the SemanticRouter embeds route utterances via a synchronous provider
@ -3654,6 +3667,7 @@ class TestSemanticKeywordTierRules:
# ...and none of it ran on the event-loop thread.
assert all(tid != loop_thread_id for tid in fake_router.sync_embedding_thread_ids)
@requires_semantic_router
@pytest.mark.asyncio
async def test_concurrent_cold_start_builds_routelayer_once(self, basic_config):
"""Concurrent first requests must not each construct the route index (which would
@ -3717,6 +3731,7 @@ class TestSemanticKeywordTierRules:
assert result is not None
assert result.model == "gpt-4o-mini" # SIMPLE via scoring fallback
@requires_semantic_router
@pytest.mark.asyncio
async def test_route_embeddings_cached_across_requests(self, basic_config):
"""The route layer is built once and reused on subsequent requests."""
@ -3932,6 +3947,7 @@ class TestKeywordOverrideEdgeCases:
)
assert router._lexical_tier_override("deploy to k8s and reason step by step") is None
@requires_semantic_router
def test_semantic_routelayer_requires_embedding_model(self, mock_router_instance, basic_config):
"""Building the route layer without an embedding model raises (defensive invariant)."""
config = {**basic_config, "keyword_tier_rules": [{"keywords": ["k8s"], "tier": "REASONING"}]}
@ -3944,6 +3960,7 @@ class TestKeywordOverrideEdgeCases:
with pytest.raises(ValueError, match="embedding_model is required"):
router._get_or_create_semantic_routelayer()
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_override_maps_first_of_list(self, mock_router_instance, basic_config):
"""A list RouteChoice result maps to the first entry's tier."""
@ -3953,6 +3970,7 @@ class TestKeywordOverrideEdgeCases:
router._semantic_routelayer = _StubRouteLayer([RouteChoice(name="COMPLEX"), RouteChoice(name="SIMPLE")])
assert await router._semantic_tier_override("anything", {}) == ComplexityTier.COMPLEX
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_override_empty_list_returns_none(self, mock_router_instance, basic_config):
"""An empty list result falls through to scoring."""
@ -3960,6 +3978,7 @@ class TestKeywordOverrideEdgeCases:
router._semantic_routelayer = _StubRouteLayer([])
assert await router._semantic_tier_override("anything", {}) is None
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_override_unknown_route_name_returns_none(self, mock_router_instance, basic_config):
"""A matched route whose name is not a ComplexityTier is ignored."""
@ -4027,6 +4046,7 @@ class TestRoutingDecisionCauseLogging:
# A literal match must not be mislabelled as semantic.
assert "cause=semantic_keyword_match" not in router_log_capture.text
@requires_semantic_router
@pytest.mark.asyncio
async def test_semantic_keyword_match_logs_its_cause(self, basic_config, router_log_capture):
fake_router = FakeEmbeddingRouter()