mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Cost map pins recheck / rerun-cost-map-guard (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust DB / db-queries (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.0.0 compatibility (push) Blocked by required conditions
unit / OpenAI 3.25.0 compatibility (push) Blocked by required conditions
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
* fix(anthropic): send an https PDF image_url to Claude as a document A PDF link sent as image_url to Claude on Anthropic went out as an image block and Anthropic answered 400. A link whose path ends in .pdf is now downloaded like Vertex links are, and the served Content-Type picks the document or image block, so CDN links that serve a PNG from a .pdf path keep working. An explicit format of application/pdf sends a url document with no download. PDF media types match regardless of case or parameters, and count_tokens picks the same block as the request. * fix(anthropic): download https PDF links off the event loop The proxy downloaded an https link ending in .pdf synchronously while building the Anthropic request, so on an async call every other request on that worker waited for the download. Those links are now inlined before the request is built, the same way http links already are, and the /v1/messages guardrail write-back does the same. A non-string format on an image_url is ignored the way it already was inside the image_url mapping, instead of raising an AttributeError. * fix(anthropic): send a PDF link the proxy cannot download as a link and keep guardrail url blocks as sent * test(anthropic): expect the PNG media type the served bytes carry at a .jpg path * fix(anthropic): bound the PDF link download and keep query-string PDF names Give up on a .pdf link after NON_LLM_CONNECTION_TIMEOUT instead of the client's 6000 s default, fall back to main's whole-URL extension check so download?file=report.pdf still counts as a PDF, treat application/x-pdf as a PDF, and set the url document's application/pdf format only for the guardrail write-back so other /v1/messages providers send what main sends * fix(anthropic): probe a .pdf link and send it to Claude as a url document Instead of downloading a .pdf link and inlining it as base64, ask the host for its first kilobyte and send the link as a url document when it serves a PDF. Every other link goes out as the url image main sends, so robots.txt handling, caching and the wire format stay as they are on main. Also restore the guardrail adapter and the content type inference to main, keep the client's url document blocks (title, context, citations, cache_control) through the guardrail write-back, treat a PDF filename in the query string as a PDF link, probe links concurrently on the sync path, cap the async probe's read at the download limit, and catch only transport, URL and SSRF failures in the probe * fix(anthropic): read only the first kilobyte when probing a PDF link, and restore each url document from its own place The probe now streams the response and keeps the first 1 KB, so a host that ignores Range no longer fills memory with up to 20 parallel bodies. The guardrail write-back now matches url documents inside a tool_result and outside one separately, since the write-back moves tool results ahead of other content * fix(anthropic): remember each PDF link probe's verdict and cap its total time, and skip the probe on Bedrock invoke * fix(anthropic): keep a PDF probe's error status for a minute, not ten --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
494 lines
18 KiB
Python
494 lines
18 KiB
Python
import asyncio
|
|
import base64
|
|
import importlib
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from collections.abc import Callable, Coroutine, Iterator
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Final, Literal, TypeAlias
|
|
|
|
import boto3
|
|
import httpx
|
|
import pytest
|
|
from pytest_socket import enable_socket, socket_allow_hosts
|
|
|
|
HOST_ENVIRONMENT_ALLOWLIST: Final = frozenset(
|
|
(
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"TMPDIR",
|
|
"TEMP",
|
|
"TMP",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
"TZ",
|
|
"VIRTUAL_ENV",
|
|
"LITELLM_LOCAL_MODEL_COST_MAP",
|
|
"TIKTOKEN_CACHE_DIR",
|
|
)
|
|
)
|
|
HOST_ENVIRONMENT_ALLOWED_PREFIXES: Final = ("PYTEST_", "PYTHON", "COV_CORE_", "COVERAGE_")
|
|
HOST_ONLY_ENVIRONMENT: Final = frozenset(
|
|
name
|
|
for name in os.environ
|
|
if name not in HOST_ENVIRONMENT_ALLOWLIST and not name.startswith(HOST_ENVIRONMENT_ALLOWED_PREFIXES)
|
|
)
|
|
|
|
os.environ["PYTHON_DOTENV_DISABLED"] = "1"
|
|
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
|
|
|
|
import litellm # noqa: E402 # litellm reads LITELLM_LOCAL_MODEL_COST_MAP at import
|
|
import litellm.router as litellm_router_module # noqa: E402 # same import-time dependency
|
|
import litellm.utils as litellm_utils_module # noqa: E402 # same import-time dependency
|
|
from litellm._logging import ALL_LOGGERS # noqa: E402 # same import-time dependency
|
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER # noqa: E402 # same import-time dependency
|
|
from litellm.anthropic_beta_headers_manager import reload_beta_headers_config # noqa: E402 # same import-time dependency
|
|
from litellm.litellm_core_utils.prompt_templates import factory as prompt_factory_module # noqa: E402 # same import-time dependency
|
|
from litellm.litellm_core_utils.prompt_templates import ( # noqa: E402 # same import-time dependency
|
|
image_handling as image_handling_module,
|
|
)
|
|
from litellm.llms.gemini.chat import transformation as gemini_chat_transformation_module # noqa: E402 # same import-time dependency
|
|
from litellm.llms.custom_httpx.async_client_cleanup import ( # noqa: E402 # same import-time dependency
|
|
close_litellm_async_clients,
|
|
)
|
|
from litellm.proxy.db import tool_registry_writer as tool_registry_writer_module # noqa: E402 # same import-time dependency
|
|
|
|
LOOPBACK_HOSTS: Final = ["127.0.0.1", "::1", "localhost"]
|
|
AMBIENT_AZURE_CREDENTIAL_ENV_VARS: Final = (
|
|
"AZURE_AD_TOKEN",
|
|
"AZURE_TENANT_ID",
|
|
"AZURE_CLIENT_ID",
|
|
"AZURE_CLIENT_SECRET",
|
|
"AZURE_USERNAME",
|
|
"AZURE_PASSWORD",
|
|
)
|
|
AMBIENT_AWS_ENV_VARS: Final = (
|
|
"AWS_PROFILE",
|
|
"AWS_DEFAULT_PROFILE",
|
|
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
|
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
|
"AWS_SESSION_TOKEN",
|
|
"AWS_ROLE_ARN",
|
|
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
|
"AWS_BEARER_TOKEN_BEDROCK",
|
|
"AWS_REGION_NAME",
|
|
"AWS_DEFAULT_REGION",
|
|
)
|
|
MODULES_WITH_AWS_AUTH_HANDLERS: Final = (
|
|
"litellm.main",
|
|
"litellm.files.main",
|
|
"litellm.rerank_api.main",
|
|
"litellm.realtime_api.main",
|
|
)
|
|
CALLBACK_LISTS: Final = (
|
|
"callbacks",
|
|
"success_callback",
|
|
"failure_callback",
|
|
"input_callback",
|
|
"_async_success_callback",
|
|
"_async_failure_callback",
|
|
"_async_input_callback",
|
|
)
|
|
RESET_TO_NONE_GLOBALS: Final = (
|
|
"model_fallbacks",
|
|
"cache",
|
|
"max_fallbacks",
|
|
"default_fallbacks",
|
|
"fallbacks",
|
|
"context_window_fallbacks",
|
|
"content_policy_fallbacks",
|
|
)
|
|
RESTORED_GLOBALS: Final = (
|
|
"disable_aiohttp_transport",
|
|
"force_ipv4",
|
|
"drop_params",
|
|
"secret_manager_client",
|
|
"_key_management_system",
|
|
"_key_management_settings",
|
|
"api_base",
|
|
"num_retries",
|
|
"modify_params",
|
|
"ssl_verify",
|
|
"credential_list",
|
|
"model_group_settings",
|
|
"default_internal_user_params",
|
|
"default_team_params",
|
|
"prometheus_emit_stream_label",
|
|
"vector_store_registry",
|
|
"model_cost",
|
|
"cost_margin_config",
|
|
"cost_discount_config",
|
|
"disable_hf_tokenizer_download",
|
|
"disable_copilot_system_to_assistant",
|
|
"cohere_models",
|
|
"anthropic_models",
|
|
"token_counter",
|
|
"initialized_langfuse_clients",
|
|
)
|
|
MODULE_LEVEL_CLIENTS: Final = ("module_level_client", "module_level_aclient")
|
|
SESSION_CLIENTS: Final = ("base_llm_aiohttp_handler", "httpx_client", "aclient", "client")
|
|
ONE_PIXEL_PNG: Final = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
|
|
def _allow_loopback_only() -> None:
|
|
socket_allow_hosts(LOOPBACK_HOSTS, allow_unix_socket=True)
|
|
|
|
|
|
_allow_loopback_only()
|
|
|
|
|
|
def pytest_collectstart() -> None:
|
|
_allow_loopback_only()
|
|
|
|
|
|
@pytest.hookimpl(trylast=True)
|
|
def pytest_runtest_setup() -> None:
|
|
_allow_loopback_only()
|
|
|
|
|
|
def _run_coroutine_if_needed(result: object) -> None:
|
|
if not asyncio.iscoroutine(result):
|
|
return
|
|
coroutine: Final[Coroutine[object, object, object]] = result
|
|
try:
|
|
asyncio.run(coroutine)
|
|
except RuntimeError:
|
|
try:
|
|
loop: Final = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
coroutine.close()
|
|
return
|
|
loop.create_task(coroutine)
|
|
|
|
|
|
async def _complete_test_logging() -> None:
|
|
await GLOBAL_LOGGING_WORKER.flush()
|
|
await GLOBAL_LOGGING_WORKER.stop()
|
|
|
|
|
|
def _flush_completed_test_logging() -> None:
|
|
queue: Final = GLOBAL_LOGGING_WORKER._queue
|
|
if queue is None or queue._unfinished_tasks == 0:
|
|
return
|
|
worker_loop: Final = GLOBAL_LOGGING_WORKER._bound_loop
|
|
if worker_loop is None or worker_loop.is_closed():
|
|
_run_coroutine_if_needed(_complete_test_logging())
|
|
return
|
|
if worker_loop.is_running():
|
|
asyncio.run_coroutine_threadsafe(_complete_test_logging(), worker_loop).result()
|
|
return
|
|
worker_loop.run_until_complete(_complete_test_logging())
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_call() -> Iterator[None]:
|
|
yield
|
|
_flush_completed_test_logging()
|
|
|
|
|
|
def _close_handler_if_needed(handler: object) -> None:
|
|
close: Final = getattr(handler, "close", None)
|
|
if not callable(close):
|
|
return
|
|
_run_coroutine_if_needed(close())
|
|
|
|
|
|
def _reset_aws_auth_caches() -> None:
|
|
modules: Final = tuple(importlib.import_module(name) for name in MODULES_WITH_AWS_AUTH_HANDLERS)
|
|
flushes: Final = (
|
|
getattr(getattr(getattr(module, attr_name), "iam_cache", None), "flush_cache", None)
|
|
for module in modules
|
|
for attr_name in dir(module)
|
|
)
|
|
for flush in filter(callable, flushes):
|
|
flush()
|
|
boto3.DEFAULT_SESSION = None
|
|
|
|
|
|
def _flush_client_caches() -> None:
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
image_handling_module.in_memory_cache.flush_cache()
|
|
image_handling_module.pdf_probe_verdicts.flush_cache()
|
|
_reset_aws_auth_caches()
|
|
|
|
|
|
_MACOS_LONGEST_SOCKET_PATH: Final = 103
|
|
_LONGEST_SOCKET_SUFFIX: Final = len("/sock12345678/d/litellm-pgbouncer-12345678/.s.PGSQL.65535")
|
|
|
|
|
|
def _socket_root() -> str:
|
|
default: Final = tempfile.gettempdir()
|
|
fits: Final = len(os.fsencode(default)) + _LONGEST_SOCKET_SUFFIX <= _MACOS_LONGEST_SOCKET_PATH
|
|
return default if fits else "/tmp"
|
|
|
|
|
|
@pytest.fixture
|
|
def socket_dir(tmp_path: Path) -> Iterator[Path]:
|
|
"""``tmp_path`` behind a short link, so unix socket paths fit the sun_path limit that it exceeds on macOS."""
|
|
link_dir: Final = Path(tempfile.mkdtemp(prefix="sock", dir=_socket_root()))
|
|
link: Final = link_dir / "d"
|
|
link.symlink_to(tmp_path, target_is_directory=True)
|
|
yield link
|
|
shutil.rmtree(link_dir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def socket_path(socket_dir: Path) -> Path:
|
|
return socket_dir / "spend.sock"
|
|
|
|
|
|
@pytest.fixture
|
|
def socket_tempdir(socket_dir: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Point ``tempfile``'s default directory at ``socket_dir``, for code that picks its own socket directory."""
|
|
monkeypatch.setattr(tempfile, "tempdir", str(socket_dir))
|
|
return socket_dir
|
|
|
|
|
|
@pytest.fixture(autouse=True, scope="session")
|
|
def bundled_tiktoken_cache() -> None:
|
|
importlib.import_module("litellm.litellm_core_utils.default_encoding")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def isolated_aws_config_files(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]:
|
|
aws_dir: Final = tmp_path_factory.mktemp("aws-config")
|
|
credentials: Final = aws_dir / "credentials"
|
|
config: Final = aws_dir / "config"
|
|
credentials.write_text("", encoding="utf-8")
|
|
config.write_text("", encoding="utf-8")
|
|
return credentials, config
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_host_environment(isolated_aws_config_files: tuple[Path, Path]) -> Iterator[None]:
|
|
credentials, config = isolated_aws_config_files
|
|
with pytest.MonkeyPatch.context() as environment:
|
|
for name in HOST_ONLY_ENVIRONMENT:
|
|
environment.delenv(name, raising=False)
|
|
environment.setenv("AWS_SHARED_CREDENTIALS_FILE", str(credentials))
|
|
environment.setenv("AWS_CONFIG_FILE", str(config))
|
|
environment.setenv("AWS_EC2_METADATA_DISABLED", "true")
|
|
for name in AMBIENT_AWS_ENV_VARS:
|
|
environment.delenv(name, raising=False)
|
|
for name in ("PROXY_BASE_URL", "SERVER_ROOT_PATH", "SERVER_ROOT_PATHS", "LITELLM_LOG"):
|
|
environment.delenv(name, raising=False)
|
|
environment.setenv("LITELLM_CLI_DISABLE_KEYRING", "1")
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_litellm_globals() -> Iterator[None]:
|
|
original_callbacks: Final = {name: list(getattr(litellm, name) or []) for name in CALLBACK_LISTS}
|
|
original_callback_holders: Final = frozenset(litellm_router_module._routers_holding_global_callbacks)
|
|
original_reset: Final = {name: getattr(litellm, name) for name in RESET_TO_NONE_GLOBALS}
|
|
original_restored: Final = {name: getattr(litellm, name) for name in RESTORED_GLOBALS if hasattr(litellm, name)}
|
|
original_clients: Final = {name: litellm.__dict__[name] for name in MODULE_LEVEL_CLIENTS if name in litellm.__dict__}
|
|
original_loggers: Final = {
|
|
logger: (logger.level, logger.disabled, logger.propagate, list(logger.handlers), list(logger.filters))
|
|
for logger in ALL_LOGGERS
|
|
}
|
|
original_tool_policy_registry: Final = tool_registry_writer_module._tool_policy_registry
|
|
_flush_client_caches()
|
|
for name in CALLBACK_LISTS:
|
|
setattr(litellm, name, [])
|
|
litellm_router_module._routers_holding_global_callbacks.clear()
|
|
for name in RESET_TO_NONE_GLOBALS:
|
|
setattr(litellm, name, None)
|
|
for name in MODULE_LEVEL_CLIENTS:
|
|
litellm.__dict__.pop(name, None)
|
|
tool_registry_writer_module._tool_policy_registry = None
|
|
yield
|
|
_flush_client_caches()
|
|
leaked_clients: Final = tuple(litellm.__dict__.pop(name, None) for name in MODULE_LEVEL_CLIENTS)
|
|
for name, client in zip(MODULE_LEVEL_CLIENTS, leaked_clients):
|
|
if client is not original_clients.get(name):
|
|
_close_handler_if_needed(client)
|
|
litellm.__dict__.update(original_clients)
|
|
for name, value in (original_callbacks | original_reset | original_restored).items():
|
|
setattr(litellm, name, value)
|
|
litellm_router_module._routers_holding_global_callbacks.clear()
|
|
litellm_router_module._routers_holding_global_callbacks.update(original_callback_holders)
|
|
for logger, (level, disabled, propagate, handlers, filters) in original_loggers.items():
|
|
logger.setLevel(level)
|
|
logger.disabled = disabled
|
|
logger.propagate = propagate
|
|
logger.handlers = handlers
|
|
logger.filters = filters
|
|
tool_registry_writer_module._tool_policy_registry = original_tool_policy_registry
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_router_model_cost_state() -> Iterator[None]:
|
|
original_live_routers: Final = frozenset(litellm_router_module._live_routers)
|
|
original_runtime_registered_model_cost: Final = {
|
|
model_key: dict(model_value)
|
|
for model_key, model_value in litellm_utils_module._runtime_registered_model_cost.items()
|
|
}
|
|
litellm_utils_module._invalidate_model_cost_lowercase_map()
|
|
litellm_router_module._live_routers.clear()
|
|
litellm_utils_module._runtime_registered_model_cost.clear()
|
|
yield
|
|
for router in tuple(litellm_router_module._live_routers):
|
|
litellm_router_module._live_routers.discard(router)
|
|
for router in original_live_routers:
|
|
litellm_router_module._live_routers.add(router)
|
|
litellm_utils_module._runtime_registered_model_cost.clear()
|
|
litellm_utils_module._runtime_registered_model_cost.update(original_runtime_registered_model_cost)
|
|
litellm_utils_module._invalidate_model_cost_lowercase_map()
|
|
litellm.get_model_info.cache_clear()
|
|
|
|
|
|
def _provider_model_sets() -> tuple[set[str], ...]:
|
|
module_globals: Final = tuple(vars(litellm).items())
|
|
return tuple(models for name, models in module_globals if name.endswith("_models") and isinstance(models, set))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_model_catalog() -> Iterator[None]:
|
|
model_cost: Final = litellm.model_cost
|
|
model_cost_entries: Final = dict(model_cost)
|
|
models_by_provider: Final = litellm.models_by_provider
|
|
models_by_provider_entries: Final = dict(models_by_provider)
|
|
provider_model_sets: Final = tuple((models, frozenset(models)) for models in _provider_model_sets())
|
|
yield
|
|
model_cost.clear()
|
|
model_cost.update(model_cost_entries)
|
|
models_by_provider.clear()
|
|
models_by_provider.update(models_by_provider_entries)
|
|
for models, entries in provider_model_sets:
|
|
models.clear()
|
|
models.update(entries)
|
|
litellm_utils_module._invalidate_model_cost_lowercase_map()
|
|
litellm.get_model_info.cache_clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
|
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
|
litellm.get_model_info.cache_clear()
|
|
yield
|
|
litellm.get_model_info.cache_clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def declare_mid_conversation_system(monkeypatch: pytest.MonkeyPatch) -> Callable[[str, bool], None]:
|
|
def declare(model_cost_key: str, supported: bool) -> None:
|
|
entry: Final = {
|
|
"litellm_provider": model_cost_key.split("/", 1)[0],
|
|
"mode": "chat",
|
|
"supports_mid_conversation_system": supported,
|
|
}
|
|
monkeypatch.setitem(litellm.model_cost, model_cost_key, entry)
|
|
litellm.get_model_info.cache_clear()
|
|
|
|
return declare
|
|
|
|
|
|
NativeStructuredOutputKey: TypeAlias = Literal["legacy", "bedrock"] | None
|
|
|
|
|
|
@pytest.fixture
|
|
def set_native_structured_output(
|
|
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> Callable[[str, NativeStructuredOutputKey], None]:
|
|
def set_flags(model_cost_key: str, native_key: NativeStructuredOutputKey) -> None:
|
|
entry: Final = litellm.model_cost[model_cost_key]
|
|
monkeypatch.setitem(entry, "supports_native_structured_output", native_key == "legacy")
|
|
monkeypatch.setitem(entry, "supports_bedrock_native_structured_output", native_key == "bedrock")
|
|
litellm.get_model_info.cache_clear()
|
|
|
|
return set_flags
|
|
|
|
|
|
@pytest.fixture
|
|
def local_beta_headers_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
|
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")
|
|
reload_beta_headers_config()
|
|
yield
|
|
monkeypatch.delenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", raising=False)
|
|
reload_beta_headers_config()
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AsyncOnlyImageFetch:
|
|
fetched: list[str] = field(default_factory=list) # mutable-ok: tests assert on the URLs fetched, in order
|
|
base64_png: str = base64.b64encode(ONE_PIXEL_PNG).decode()
|
|
data_url: str = "data:image/png;base64," + base64.b64encode(ONE_PIXEL_PNG).decode()
|
|
|
|
|
|
@pytest.fixture
|
|
def async_only_image_fetch(monkeypatch: pytest.MonkeyPatch) -> AsyncOnlyImageFetch:
|
|
fetch: Final = AsyncOnlyImageFetch()
|
|
|
|
def forbid_sync_fetch(client: object, url: str, **kwargs: object) -> httpx.Response:
|
|
raise litellm.ImageFetchError(f"sync image fetch ran on the event loop: {url}")
|
|
|
|
async def serve_png(client: object, url: str, **kwargs: object) -> httpx.Response:
|
|
fetch.fetched.append(url)
|
|
return httpx.Response(
|
|
200, content=ONE_PIXEL_PNG, headers={"content-type": "image/png"}, request=httpx.Request("GET", url)
|
|
)
|
|
|
|
def forbid_sync_convert(url: str, *args: object, **kwargs: object) -> str:
|
|
if url.startswith(("http://", "https://")):
|
|
raise litellm.ImageFetchError(f"sync convert_url_to_base64 ran on the request path: {url}")
|
|
return url
|
|
|
|
monkeypatch.setattr(image_handling_module, "safe_get", forbid_sync_fetch)
|
|
monkeypatch.setattr(image_handling_module, "async_safe_get", serve_png)
|
|
for module in (image_handling_module, prompt_factory_module, gemini_chat_transformation_module):
|
|
monkeypatch.setattr(module, "convert_url_to_base64", forbid_sync_convert)
|
|
return fetch
|
|
|
|
|
|
@pytest.fixture
|
|
def no_ambient_azure_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name in AMBIENT_AZURE_CREDENTIAL_ENV_VARS:
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
FAKE_PROVIDER_CREDENTIALS: Final = MappingProxyType(
|
|
{
|
|
"OPENAI_API_KEY": "sk-unit-test",
|
|
"ANTHROPIC_API_KEY": "sk-ant-unit-test",
|
|
"GEMINI_API_KEY": "unit-test",
|
|
"AZURE_API_KEY": "unit-test",
|
|
"AZURE_API_BASE": "https://unit-test.openai.azure.com",
|
|
"AZURE_API_VERSION": "2024-02-01",
|
|
"AWS_ACCESS_KEY_ID": "unit-test",
|
|
"AWS_SECRET_ACCESS_KEY": "unit-test",
|
|
"AWS_REGION_NAME": "us-east-1",
|
|
"COHERE_API_KEY": "unit-test",
|
|
"DD_API_KEY": "unit-test",
|
|
"DD_SITE": "us5.datadoghq.com",
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_provider_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name, value in FAKE_PROVIDER_CREDENTIALS.items():
|
|
monkeypatch.setenv(name, value)
|
|
|
|
|
|
@pytest.fixture
|
|
async def drained_logging_worker() -> None:
|
|
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.clear_queue(), timeout=10)
|
|
|
|
|
|
def pytest_sessionfinish() -> None:
|
|
for name in MODULE_LEVEL_CLIENTS:
|
|
_close_handler_if_needed(litellm.__dict__.pop(name, None))
|
|
for name in SESSION_CLIENTS:
|
|
_close_handler_if_needed(getattr(litellm, name, None))
|
|
_run_coroutine_if_needed(close_litellm_async_clients())
|
|
enable_socket()
|