litellm/tests/unit/conftest.py
Mateo Wang 36c64ce85d
feat(bedrock): default to native structured output on Opus 5.5, Fable 5.1, Sonnet 5 and Haiku 5.5 (#50221)
* feat(bedrock): enable native structured output by default for selected Claude models

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): keep govcloud and bare model rows off native output

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): retry rejected native schemas with the JSON tool on default-native models

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): keep the rejection retry on the unforceable-tool native path

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): retry rejected schemas on the Messages path for default-native models

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(bedrock): format Messages native output retry

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(bedrock): allow profile-only native output metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): only disable default-native output on schema-rejection retries

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(bedrock): type fixture parameters in native output retry tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): retry with the JSON tool when Bedrock rejects a schema's grammar size

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): drop strict tools after a native-output retry hits the grammar limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(bedrock): format native output fallback

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bedrock): honor the deployment native output opt-out and keep wide batch schemas on the JSON tool

A deployment's supports_bedrock_native_structured_output: false now keeps the JSON tool on rows that default to native output. Bedrock batch has no retry, so a record whose schema has more than 64 properties keeps the JSON tool there too.

* fix(bedrock): retry a native structured output stream as a buffered stream when Bedrock denies the stream action

A role allowed only bedrock:InvokeModel got a 403 on schema streams once these
models went native, since native streams call converse-stream while the JSON
tool path buffered through converse. The retry now sends that request again
as a buffered JSON tool stream, the shape the base sent.

The wire test moves its neither-key and opt-in cells to Opus 4.7, its
Bedrock-key-only cells to Sonnet 5.5, and its unforceable-tool cells to the
bare Opus 5.5 id, since this PR makes Opus 5.5 and Haiku 5.5 always native.

The batch wire test now checks a thinking schema record on both sides of the
contract the batch unit test already pins, the JSON tool on opt-in Sonnet 5.5
and native output on always-native Haiku 5.5, since its Haiku 5.5 and Opus 4.8
cells assumed those models were opt-in.

* fix(bedrock): move the Claude 5 native output default to a key released proxies do not read

Released proxies load the cost map from main and have none of the native
output retries, so setting supports_native_structured_output on the Opus
5.5, Fable 5.1, Sonnet 5 and Haiku 5.5 profile rows turned working wide
or min/max schemas into 400s on v1.108.1.

Those rows keep supports_native_structured_output false and gain
bedrock_native_structured_output_by_default true, which get_model_info
reports as supports_native_structured_output, so this version behaves as
before while released versions keep the JSON tool.

* fix(bedrock): rename the Claude 5 native output default key to supports_bedrock_native_structured_output_by_default

The cost map guard runs main's model catalog on the PR's cost map, and that catalog rejects any undeclared key that does not start with supports_. Declaring the old name on main first needs its own PR, so the key takes the supports_ prefix. Released proxies still never read it

* fix(bedrock): honor registered and batch opt-outs, move Opus 5 and 4.8 to the default key

A registered supports_bedrock_native_structured_output false now beats the
native output default, and batch uploads read the deployment's model_info, so
an opted-out deployment keeps the JSON tool on every record. The Opus 5 and
Opus 4.8 profile rows move to supports_bedrock_native_structured_output_by_default,
so released proxies on main's cost map keep the JSON tool on them. The 64
property batch cutoff now applies only to rows with that key, so models that
were already native keep their batch output shape.

* fix(proxy): report a deployment's Bedrock native output opt-out on /model/info

/model/info merged the deployment's model_info over the backend row, so a
deployment that set only supports_bedrock_native_structured_output: false
still showed the backend's derived supports_native_structured_output: true.
The generic flag is now derived from the merged info unless the deployment
set it itself, through the same helper get_model_info uses.

* fix(bedrock): keep native output when a denied schema stream is resent buffered

A role without bedrock:InvokeModelWithResponseStream had its native output schema stream resent as a buffered JSON tool call. The resend now keeps the native params with fake_stream set, and falls back to the JSON tool only if Bedrock then rejects native output.

* fix(bedrock): drop rejected strict tools on the JSON tool retry after a buffered native resend

* fix(bedrock): send a buffered schema stream to /invoke and keep the by-default key on its deployment

The shared HTTP handler now builds the non-streaming URL for a fake-streamed request, so the invoke route's buffered resend after a denied stream calls /invoke instead of invoke-with-response-stream. supports_bedrock_native_structured_output_by_default is deployment scoped, so setting it on one deployment no longer reaches sibling deployments of the same model

* fix(bedrock): answer a buffered invoke stream from the non-streaming reply

* fix(bedrock): keep the JSON tool retry buffered after a denied stream's schema is rejected

* fix(bedrock): count only a true fake_stream and keep the deployment by-default key out of /model/info

A fake_stream sent as "true" or 1 picked the non-streaming URL but kept the streaming reader, so Bedrock invoke streams answered 500 after three billed calls. Only a real True counts now, as on main.

/model/info read supports_bedrock_native_structured_output_by_default from the deployment, while routing reads it only from the cost map, so it reported native output for deployments that use the JSON tool.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-11-06 09:38:00 -08:00

598 lines
22 KiB
Python

import asyncio
import base64
import importlib
import os
import shutil
import tempfile
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
import boto3
import httpx
import pytest
from pytest_socket import enable_socket, socket_allow_hosts
if TYPE_CHECKING:
from unittest.mock import MagicMock
from litellm.caching.redis_cache import RedisCache
RouterRedis: TypeAlias = tuple["RedisCache", asyncio.Queue[tuple[tuple[object, ...], str | None, str | None]]]
RedisWriteGate: TypeAlias = tuple[asyncio.Event, asyncio.Event, asyncio.Event]
def redis_cache_double() -> "MagicMock":
"""A `RedisCache` double for router tests. The native router stores connect from its settings, which name
a closed local port, so they serve their memory tier while the double answers the Python Redis calls."""
from unittest.mock import MagicMock
from litellm.caching.redis_cache import RedisCache
from litellm.redis.store_settings import RedisStoreSettings, breaker_settings
redis: Final = MagicMock(spec=RedisCache)
redis.redis_kwargs = {"host": "127.0.0.1", "port": 1, "socket_timeout": 0.2}
redis.namespace = None
redis.store_settings = RedisStoreSettings(
redis_kwargs=redis.redis_kwargs, namespace=None, default_ttl=60, breaker=breaker_settings()
)
return redis
@pytest.fixture
def redis_write_gate() -> RedisWriteGate:
released: Final = asyncio.Event()
released.set()
return asyncio.Event(), released, asyncio.Event()
@pytest.fixture
async def router_redis(
redis_write_gate: RedisWriteGate,
) -> AsyncIterator[RouterRedis]:
from unittest.mock import patch
from fakeredis import FakeRedis, FakeServer
from fakeredis.aioredis import FakeConnection
from opentelemetry import trace
from redis.asyncio import BlockingConnectionPool
from redis.exceptions import ConnectionError
from litellm._internal_context import current_service_target
from litellm.caching.redis_cache import RedisCache
commands: Final[asyncio.Queue[tuple[tuple[object, ...], str | None, str | None]]] = asyncio.Queue()
started, released, unavailable = redis_write_gate
class ObservedConnection(FakeConnection):
async def send_command(self, *args: object, **kwargs: object) -> None: # kwargs-ok: Redis transport signature
commands.put_nowait((args, current_service_target(), getattr(trace.get_current_span(), "name", None)))
await super().send_command(*args, **kwargs)
async def send_packed_command(self, command: Iterable[bytes] | bytes | str, check_health: bool = True) -> None:
chunks: Final = (
(command.encode(),)
if isinstance(command, str)
else (command,)
if isinstance(command, bytes)
else command
)
wire: Final = b"".join(chunks)
if b"INCRBYFLOAT" in wire:
commands.put_nowait(
((wire,), current_service_target(), getattr(trace.get_current_span(), "name", None))
)
started.set()
await released.wait()
if unavailable.is_set():
raise ConnectionError("scripted write outage")
await super().send_packed_command(command, check_health=check_health)
server: Final = FakeServer()
client: Final = FakeRedis(server=server)
pool: Final = BlockingConnectionPool(connection_class=ObservedConnection, server=server)
try:
with (
patch("redis.Redis.from_url", return_value=client),
patch("redis.asyncio.BlockingConnectionPool.from_url", return_value=pool),
):
yield RedisCache(url="redis://router-unit.example:6379/0"), commands
finally:
released.set()
await pool.aclose()
client.close()
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.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.anthropic_beta_headers_manager import (
reload_beta_headers_config, # 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.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.custom_httpx.async_client_cleanup import ( # noqa: E402 # same import-time dependency
close_litellm_async_clients,
)
from litellm.llms.gemini.chat import (
transformation as gemini_chat_transformation_module, # noqa: E402 # same import-time dependency
)
from litellm.proxy.db import (
tool_registry_writer as tool_registry_writer_module, # noqa: E402 # same import-time dependency
)
from litellm.router_rust.surface.replay import ( # noqa: E402 # same import-time dependency
_live_registries as live_router_registries, # pyright: ignore[reportPrivateUsage] # the routers the model-cost replay reaches
)
from litellm.router_utils.global_callback_holders import ( # noqa: E402 # same import-time dependency
routers_holding_global_callbacks,
)
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(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, [])
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)
routers_holding_global_callbacks.clear()
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_registries: Final = frozenset(live_router_registries)
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()
live_router_registries.clear()
litellm_utils_module._runtime_registered_model_cost.clear()
yield
live_router_registries.clear()
live_router_registries.update(original_live_registries)
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", "default"] | 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 in ("bedrock", "default"))
monkeypatch.setitem(entry, "supports_bedrock_native_structured_output_by_default", native_key == "default")
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()