mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rename fork-flag to unit-flag now that it applies on every event * test: move tests/test_litellm root and small trees into tests/unit Pure renames, no content changes. Follow-up commits in this PR fix references, merge the three files that already existed in tests/unit, keep live-provider tests in tests/test_litellm and wire CI. * test: carry tests/test_litellm conftest isolation into tests/unit Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS, proxy-URL and keychain env, and session-end client cleanup now reset for unit tests too. The environment isolation owns its MonkeyPatch so a test's own monkeypatch is undone before the model-cost teardown runs. * test: merge, split and prune the moved root and small-tree tests Merge batches/test_batch_utils.py and the chat_completions and messages dispatch tests into the files that already existed in tests/unit. Keep the live Gemini interactions tests, the async image-fetch format test and the OpenAI embedding scorer test in tests/test_litellm since they need real network or keys. Put test_router.py under tests/unit/test_router so the existing package no longer shadows it. Delete eight tests the audit found superseded by stronger ones kept in this move. * ci: run the moved root and small-tree tests under their legacy flags Add the misc and responses-caching-types flags to unit_selection.sh and CircleCI, extend enterprise-routing and mcp-integration, and point the legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest and change classifier at the new paths. * test: make the new tests/unit directories packages tests/unit/test_package_layout.py requires every directory to carry an __init__.py, and without one the moved and retained test_litellm_responses_bridge.py modules collide on import. * test: scope the unit socket block to tests/unit in shared sessions The GHA shards collect the legacy test-path and the unit selection in one pytest session. The unit conftest's loopback-only block leaked into legacy modules that reach the network at import. The legacy conftest now lifts the restriction at collect and setup time, and the unit conftest re-applies it when collecting its own modules. * test: give the shard-script tests their own GITHUB_OUTPUT They only passed where the runner set it. The CircleCI unit job's env allowlist drops it, so the script's redirect failed there. * test: point the router and module-deletion checks at tests/unit router_code_coverage and code_qa_check_tests only searched tests/test_litellm, so the moved router tests no longer counted. The two silent-experiment tests the audit deleted were the only direct callers of those methods; they are replaced with tests that assert the forwarded shadow request and the recursion guard. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
584 lines
19 KiB
Python
584 lines
19 KiB
Python
"""
|
|
Unit tests for litellm/interactions/agents/http_handler.py
|
|
|
|
These tests exercise both the sync and async branches of every CRUD method
|
|
on AgentsHTTPHandler using stub httpx clients, plus the _is_async dispatch
|
|
branches, error mapping, and pre/post logging hooks.
|
|
|
|
No real HTTP traffic is made.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
from litellm.interactions.agents.http_handler import (
|
|
AgentsHTTPHandler,
|
|
agents_http_handler,
|
|
)
|
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
|
from litellm.llms.gemini.agents.transformation import GeminiAgentsConfig
|
|
from litellm.llms.gemini.common_utils import GeminiError
|
|
from litellm.types.agents import (
|
|
AgentCreateResponse,
|
|
AgentDeleteResult,
|
|
AgentListResponse,
|
|
AgentVersionsResponse,
|
|
)
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_response(status_code: int = 200, json_data=None, text: str = "") -> MagicMock:
|
|
"""Build a stub httpx-like response."""
|
|
response = MagicMock()
|
|
response.status_code = status_code
|
|
response.text = text or (str(json_data) if json_data is not None else "")
|
|
response.headers = {}
|
|
if json_data is not None:
|
|
response.json.return_value = json_data
|
|
else:
|
|
response.json.return_value = {}
|
|
return response
|
|
|
|
|
|
def _make_sync_client() -> MagicMock:
|
|
client = MagicMock(spec=HTTPHandler)
|
|
return client
|
|
|
|
|
|
def _make_async_client() -> MagicMock:
|
|
client = MagicMock(spec=AsyncHTTPHandler)
|
|
client.post = AsyncMock()
|
|
client.get = AsyncMock()
|
|
client.delete = AsyncMock()
|
|
return client
|
|
|
|
|
|
def _make_logging_obj() -> MagicMock:
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def handler() -> AgentsHTTPHandler:
|
|
return AgentsHTTPHandler()
|
|
|
|
|
|
@pytest.fixture
|
|
def config() -> GeminiAgentsConfig:
|
|
return GeminiAgentsConfig()
|
|
|
|
|
|
@pytest.fixture
|
|
def litellm_params() -> GenericLiteLLMParams:
|
|
return GenericLiteLLMParams(api_key="AIza-test")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level singleton sanity check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_module_singleton_is_agents_http_handler_instance():
|
|
assert isinstance(agents_http_handler, AgentsHTTPHandler)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CREATE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateAgent:
|
|
def test_sync_returns_parsed_create_response(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
client.post.return_value = _make_response(
|
|
200, json_data={"id": "agent-x", "base_agent": "gemini-2.5-flash"}
|
|
)
|
|
logging_obj = _make_logging_obj()
|
|
|
|
result = handler.create_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=logging_obj,
|
|
extra_headers={"X-Test": "1"},
|
|
extra_body={"foo": "bar"},
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentCreateResponse)
|
|
assert result.id == "agent-x"
|
|
client.post.assert_called_once()
|
|
kwargs = client.post.call_args.kwargs
|
|
assert kwargs["url"].endswith("/v1beta/agents")
|
|
assert kwargs["json"]["name"] == "agent-x"
|
|
assert kwargs["json"]["foo"] == "bar"
|
|
assert kwargs["headers"]["X-Test"] == "1"
|
|
logging_obj.pre_call.assert_called_once()
|
|
logging_obj.post_call.assert_called_once()
|
|
|
|
def test_sync_dispatches_to_async_when_is_async(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_sync_client()
|
|
|
|
result = handler.create_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
_is_async=True,
|
|
)
|
|
|
|
import asyncio
|
|
|
|
assert asyncio.iscoroutine(result)
|
|
result.close()
|
|
|
|
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
bad = _make_response(404, text="not found")
|
|
client.post.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
handler.create_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_returns_parsed_create_response(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
client.post.return_value = _make_response(
|
|
200, json_data={"id": "agent-y", "base_agent": "gemini-2.5-flash"}
|
|
)
|
|
|
|
result = await handler.async_create_agent(
|
|
agents_api_config=config,
|
|
name="agent-y",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
extra_body={"baz": "qux"},
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentCreateResponse)
|
|
assert result.id == "agent-y"
|
|
client.post.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_maps_http_error_via_config(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
bad = _make_response(500, text="server error")
|
|
client.post.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
await handler.async_create_agent(
|
|
agents_api_config=config,
|
|
name="agent-y",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LIST
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListAgents:
|
|
def test_sync_returns_list_response(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
client.get.return_value = _make_response(
|
|
200,
|
|
json_data={
|
|
"agents": [{"id": "a-1"}, {"id": "a-2"}],
|
|
"nextPageToken": "tok",
|
|
},
|
|
)
|
|
|
|
result = handler.list_agents(
|
|
agents_api_config=config,
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentListResponse)
|
|
assert len(result.agents) == 2
|
|
assert result.next_page_token == "tok"
|
|
client.get.assert_called_once()
|
|
|
|
def test_sync_dispatches_to_async_when_is_async(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_sync_client()
|
|
|
|
result = handler.list_agents(
|
|
agents_api_config=config,
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
_is_async=True,
|
|
)
|
|
|
|
import asyncio
|
|
|
|
assert asyncio.iscoroutine(result)
|
|
result.close()
|
|
|
|
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
bad = _make_response(403, text="forbidden")
|
|
client.get.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
handler.list_agents(
|
|
agents_api_config=config,
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_returns_list_response(self, handler, config, litellm_params):
|
|
client = _make_async_client()
|
|
client.get.return_value = _make_response(
|
|
200, json_data={"agents": [{"id": "a-1"}]}
|
|
)
|
|
|
|
result = await handler.async_list_agents(
|
|
agents_api_config=config,
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentListResponse)
|
|
assert len(result.agents) == 1
|
|
client.get.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_maps_http_error_via_config(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
bad = _make_response(429, text="rate limited")
|
|
client.get.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
await handler.async_list_agents(
|
|
agents_api_config=config,
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetAgent:
|
|
def test_sync_returns_get_response(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
client.get.return_value = _make_response(200, json_data={"id": "agent-x"})
|
|
|
|
result = handler.get_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentCreateResponse)
|
|
assert result.id == "agent-x"
|
|
kwargs = client.get.call_args.kwargs
|
|
assert kwargs["url"].endswith("/v1beta/agents/agent-x")
|
|
|
|
def test_sync_dispatches_to_async_when_is_async(
|
|
self, handler, config, litellm_params
|
|
):
|
|
result = handler.get_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=_make_sync_client(),
|
|
_is_async=True,
|
|
)
|
|
import asyncio
|
|
|
|
assert asyncio.iscoroutine(result)
|
|
result.close()
|
|
|
|
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
bad = _make_response(404, text="not found")
|
|
client.get.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
handler.get_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_returns_get_response(self, handler, config, litellm_params):
|
|
client = _make_async_client()
|
|
client.get.return_value = _make_response(200, json_data={"id": "agent-y"})
|
|
|
|
result = await handler.async_get_agent(
|
|
agents_api_config=config,
|
|
name="agent-y",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentCreateResponse)
|
|
assert result.id == "agent-y"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_maps_http_error_via_config(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
bad = _make_response(404, text="not found")
|
|
client.get.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
await handler.async_get_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DELETE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeleteAgent:
|
|
def test_sync_returns_delete_result(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
client.delete.return_value = _make_response(200, json_data={})
|
|
|
|
result = handler.delete_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentDeleteResult)
|
|
assert result.name == "agent-x"
|
|
assert result.deleted is True
|
|
kwargs = client.delete.call_args.kwargs
|
|
assert kwargs["url"].endswith("/v1beta/agents/agent-x")
|
|
|
|
def test_sync_dispatches_to_async_when_is_async(
|
|
self, handler, config, litellm_params
|
|
):
|
|
result = handler.delete_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=_make_sync_client(),
|
|
_is_async=True,
|
|
)
|
|
import asyncio
|
|
|
|
assert asyncio.iscoroutine(result)
|
|
result.close()
|
|
|
|
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
bad = _make_response(403, text="forbidden")
|
|
client.delete.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
handler.delete_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_returns_delete_result(self, handler, config, litellm_params):
|
|
client = _make_async_client()
|
|
client.delete.return_value = _make_response(200, json_data={})
|
|
|
|
result = await handler.async_delete_agent(
|
|
agents_api_config=config,
|
|
name="agent-y",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentDeleteResult)
|
|
assert result.name == "agent-y"
|
|
assert result.deleted is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_maps_http_error_via_config(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
bad = _make_response(500, text="server error")
|
|
client.delete.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
await handler.async_delete_agent(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LIST VERSIONS
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListAgentVersions:
|
|
def test_sync_returns_versions_response(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
client.get.return_value = _make_response(
|
|
200,
|
|
json_data={
|
|
"agentVersions": [
|
|
{"agent": "agent-x", "name": "agents/agent-x/versions/v1"}
|
|
],
|
|
"nextPageToken": "tok",
|
|
},
|
|
)
|
|
|
|
result = handler.list_agent_versions(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentVersionsResponse)
|
|
assert len(result.agent_versions) == 1
|
|
assert result.next_page_token == "tok"
|
|
kwargs = client.get.call_args.kwargs
|
|
assert kwargs["url"].endswith("/v1beta/agents/agent-x/versions")
|
|
|
|
def test_sync_dispatches_to_async_when_is_async(
|
|
self, handler, config, litellm_params
|
|
):
|
|
result = handler.list_agent_versions(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=_make_sync_client(),
|
|
_is_async=True,
|
|
)
|
|
import asyncio
|
|
|
|
assert asyncio.iscoroutine(result)
|
|
result.close()
|
|
|
|
def test_sync_maps_http_error_via_config(self, handler, config, litellm_params):
|
|
client = _make_sync_client()
|
|
bad = _make_response(404, text="not found")
|
|
client.get.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
handler.list_agent_versions(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_returns_versions_response(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
client.get.return_value = _make_response(200, json_data={"agentVersions": []})
|
|
|
|
result = await handler.async_list_agent_versions(
|
|
agents_api_config=config,
|
|
name="agent-y",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|
|
|
|
assert isinstance(result, AgentVersionsResponse)
|
|
assert result.agent_versions == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_maps_http_error_via_config(
|
|
self, handler, config, litellm_params
|
|
):
|
|
client = _make_async_client()
|
|
bad = _make_response(500, text="server error")
|
|
client.get.side_effect = httpx.HTTPStatusError(
|
|
"boom", request=MagicMock(), response=bad
|
|
)
|
|
|
|
with pytest.raises(GeminiError):
|
|
await handler.async_list_agent_versions(
|
|
agents_api_config=config,
|
|
name="agent-x",
|
|
litellm_params=litellm_params,
|
|
logging_obj=_make_logging_obj(),
|
|
client=client,
|
|
)
|