mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
test: move logging callback and pass-through unit legacy tests to their tiers
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
afe7157e7f
commit
0d91288ecc
55 changed files with 1320 additions and 4413 deletions
|
|
@ -0,0 +1,297 @@
|
|||
from typing import Final, cast
|
||||
|
||||
import pytest
|
||||
from anthropic.types import Message, MessageParam, TextBlock, ToolUnionParam
|
||||
from e2e_config import unique_marker
|
||||
from e2e_metadata import Domain, Mode, Provider, Route, Subject, meta
|
||||
from lifecycle import ResourceManager
|
||||
from models import LiteLLMParamsBody
|
||||
from proxy_client import ProxyClient
|
||||
from sdk_clients import NO_PROXY_CACHE, SdkClients
|
||||
|
||||
BEDROCK_MODEL_ID: Final = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
BEDROCK_BACKENDS: Final = (
|
||||
pytest.param(f"bedrock/converse/{BEDROCK_MODEL_ID}", id="bedrock-converse"),
|
||||
pytest.param(f"bedrock/invoke/{BEDROCK_MODEL_ID}", id="bedrock-invoke"),
|
||||
)
|
||||
ANTHROPIC_MODEL: Final = "anthropic/claude-sonnet-4-5-20250929"
|
||||
TOOL_SEARCH_BACKENDS: Final = (
|
||||
pytest.param(ANTHROPIC_MODEL, id="anthropic"),
|
||||
*BEDROCK_BACKENDS,
|
||||
)
|
||||
def _tool_search_tools() -> list[ToolUnionParam]:
|
||||
return [
|
||||
cast(
|
||||
ToolUnionParam,
|
||||
{"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"},
|
||||
),
|
||||
cast(
|
||||
ToolUnionParam,
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a location",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"location": {"type": "string"}},
|
||||
"required": ["location"],
|
||||
},
|
||||
"defer_loading": True,
|
||||
},
|
||||
),
|
||||
cast(
|
||||
ToolUnionParam,
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get the current stock price for a ticker symbol",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"ticker": {"type": "string"}},
|
||||
"required": ["ticker"],
|
||||
},
|
||||
"defer_loading": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
CACHED_DOCUMENT: Final = "This agreement describes payment terms, renewal dates, and service obligations. " * 120
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def _register_deployment(
|
||||
proxy: ProxyClient,
|
||||
resources: ResourceManager,
|
||||
params: LiteLLMParamsBody,
|
||||
prefix: str,
|
||||
) -> tuple[str, str]:
|
||||
model_name: Final = f"{prefix}-{unique_marker()}"
|
||||
model_id: Final = proxy.create_model(model_name, params)
|
||||
resources.defer(lambda: proxy.delete_model(model_id))
|
||||
return model_name, resources.key()
|
||||
|
||||
|
||||
def _deployment_params(backend: str) -> LiteLLMParamsBody:
|
||||
if not backend.startswith("bedrock/"):
|
||||
return LiteLLMParamsBody(model=backend, api_key="os.environ/ANTHROPIC_API_KEY")
|
||||
return LiteLLMParamsBody(
|
||||
model=backend,
|
||||
aws_access_key_id="os.environ/AWS_BEDROCK_TEST_ACCESS_KEY_ID",
|
||||
aws_secret_access_key="os.environ/AWS_BEDROCK_TEST_SECRET_ACCESS_KEY",
|
||||
aws_region_name="us-east-1",
|
||||
)
|
||||
|
||||
|
||||
def _cached_messages(marker: str) -> list[MessageParam]:
|
||||
return [
|
||||
cast(
|
||||
MessageParam,
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"{marker} {CACHED_DOCUMENT}",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{"type": "text", "text": "What are the payment terms?"},
|
||||
],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _message_text(message: Message) -> str:
|
||||
return "".join(block.text for block in message.content if isinstance(block, TextBlock))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", BEDROCK_BACKENDS)
|
||||
@pytest.mark.covers(
|
||||
"llm.messages.bedrock_converse.prompt_cache_5m.nonstream.works",
|
||||
"llm.messages.bedrock_invoke.prompt_cache_5m.nonstream.works",
|
||||
)
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.BEDROCK,),
|
||||
models=(BEDROCK_MODEL_ID,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_bedrock_messages_prompt_caching_creates_cache(
|
||||
proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients, backend: str
|
||||
) -> None:
|
||||
model, key = _register_deployment(proxy, resources, _deployment_params(backend), "e2e-prompt-cache-create")
|
||||
client = sdk.anthropic(key)
|
||||
message = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=100,
|
||||
messages=_cached_messages(unique_marker()),
|
||||
extra_body=NO_PROXY_CACHE,
|
||||
)
|
||||
|
||||
assert message.role == "assistant", f"unexpected role: {message.role!r}"
|
||||
assert message.usage.cache_creation_input_tokens > 0 or message.usage.cache_read_input_tokens > 0, (
|
||||
f"prompt cache was not created or read: {message.usage!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", BEDROCK_BACKENDS)
|
||||
@pytest.mark.covers(
|
||||
"llm.messages.bedrock_converse.prompt_cache_5m.nonstream.works",
|
||||
"llm.messages.bedrock_invoke.prompt_cache_5m.nonstream.works",
|
||||
)
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.BEDROCK,),
|
||||
models=(BEDROCK_MODEL_ID,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_bedrock_messages_prompt_caching_reads_cache_on_second_call(
|
||||
proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients, backend: str
|
||||
) -> None:
|
||||
model, key = _register_deployment(proxy, resources, _deployment_params(backend), "e2e-prompt-cache-read")
|
||||
client = sdk.anthropic(key)
|
||||
messages: Final = _cached_messages(unique_marker())
|
||||
|
||||
client.messages.create(model=model, max_tokens=100, messages=messages, extra_body=NO_PROXY_CACHE)
|
||||
message = client.messages.create(model=model, max_tokens=100, messages=messages, extra_body=NO_PROXY_CACHE)
|
||||
|
||||
assert message.usage.cache_read_input_tokens > 0, f"cache read was not reported: {message.usage!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", BEDROCK_BACKENDS)
|
||||
@pytest.mark.covers(
|
||||
"llm.messages.bedrock_converse.basic.stream.works",
|
||||
"llm.messages.bedrock_invoke.basic.stream.works",
|
||||
)
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.BEDROCK,),
|
||||
models=(BEDROCK_MODEL_ID,),
|
||||
mode=Mode.STREAM,
|
||||
)
|
||||
)
|
||||
def test_bedrock_messages_streaming_prompt_caching_reads_cache(
|
||||
proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients, backend: str
|
||||
) -> None:
|
||||
model, key = _register_deployment(proxy, resources, _deployment_params(backend), "e2e-prompt-cache-stream")
|
||||
client = sdk.anthropic(key)
|
||||
messages: Final = _cached_messages(unique_marker())
|
||||
client.messages.create(model=model, max_tokens=100, messages=messages, extra_body=NO_PROXY_CACHE)
|
||||
|
||||
with client.messages.stream(
|
||||
model=model,
|
||||
max_tokens=100,
|
||||
messages=messages,
|
||||
extra_body=NO_PROXY_CACHE,
|
||||
) as stream:
|
||||
message: Final = stream.get_final_message()
|
||||
|
||||
assert message.usage.cache_read_input_tokens > 0, f"streaming cache read was not reported: {message.usage!r}"
|
||||
assert _message_text(message).strip(), f"streaming response contained no text: {message.content!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", TOOL_SEARCH_BACKENDS)
|
||||
@pytest.mark.covers(
|
||||
"llm.messages.anthropic.tool_search.nonstream.works",
|
||||
"llm.messages.bedrock_converse.tool_search.nonstream.works",
|
||||
"llm.messages.bedrock_invoke.tool_search.nonstream.works",
|
||||
)
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.ANTHROPIC, Provider.BEDROCK),
|
||||
models=(ANTHROPIC_MODEL, BEDROCK_MODEL_ID),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_anthropic_tool_search_returns_a_message(
|
||||
proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients, backend: str
|
||||
) -> None:
|
||||
model, key = _register_deployment(proxy, resources, _deployment_params(backend), "e2e-tool-search")
|
||||
message = sdk.anthropic(key).messages.create(
|
||||
model=model,
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": f"Find the weather tool for San Francisco. {unique_marker()}"}],
|
||||
tools=_tool_search_tools(),
|
||||
extra_headers={"anthropic-beta": "tool-search-tool-2025-10-19,advanced-tool-use-2025-11-20"},
|
||||
extra_body=NO_PROXY_CACHE,
|
||||
)
|
||||
|
||||
assert message.role == "assistant", f"unexpected role: {message.role!r}"
|
||||
assert message.content, "tool search response contained no content"
|
||||
assert message.usage.input_tokens > 0, f"tool search response had no input usage: {message.usage!r}"
|
||||
|
||||
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.ANTHROPIC, Provider.BEDROCK),
|
||||
models=(ANTHROPIC_MODEL, BEDROCK_MODEL_ID),
|
||||
mode=Mode.STREAM,
|
||||
)
|
||||
)
|
||||
@pytest.mark.parametrize("backend", TOOL_SEARCH_BACKENDS)
|
||||
@pytest.mark.covers(
|
||||
"llm.messages.anthropic.tool_search.nonstream.works",
|
||||
"llm.messages.bedrock_converse.tool_search.nonstream.works",
|
||||
"llm.messages.bedrock_invoke.tool_search.nonstream.works",
|
||||
)
|
||||
def test_anthropic_tool_search_streaming_returns_a_message(
|
||||
proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients, backend: str
|
||||
) -> None:
|
||||
model, key = _register_deployment(proxy, resources, _deployment_params(backend), "e2e-tool-search-stream")
|
||||
client = sdk.anthropic(key)
|
||||
|
||||
with client.messages.stream(
|
||||
model=model,
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": f"Find the weather tool for Tokyo. {unique_marker()}"}],
|
||||
tools=_tool_search_tools(),
|
||||
extra_headers={"anthropic-beta": "tool-search-tool-2025-10-19,advanced-tool-use-2025-11-20"},
|
||||
extra_body=NO_PROXY_CACHE,
|
||||
) as stream:
|
||||
message: Final = stream.get_final_message()
|
||||
|
||||
assert message.role == "assistant", f"unexpected role: {message.role!r}"
|
||||
assert message.content, "streaming tool search response contained no content"
|
||||
assert message.usage.input_tokens > 0, f"streaming tool search response had no input usage: {message.usage!r}"
|
||||
|
||||
|
||||
@pytest.mark.covers("llm.messages.bedrock_invoke.basic.nonstream.works")
|
||||
@meta(
|
||||
Subject(
|
||||
domain=Domain.LLM_TRANSLATION,
|
||||
route=Route.MESSAGES,
|
||||
providers=(Provider.BEDROCK,),
|
||||
models=(BEDROCK_MODEL_ID,),
|
||||
mode=Mode.NONSTREAM,
|
||||
)
|
||||
)
|
||||
def test_bedrock_invoke_messages_accepts_forwarded_headers(
|
||||
proxy: ProxyClient, resources: ResourceManager, sdk: SdkClients
|
||||
) -> None:
|
||||
params: Final = _deployment_params(f"bedrock/invoke/{BEDROCK_MODEL_ID}")
|
||||
model, key = _register_deployment(proxy, resources, params, "e2e-bedrock-forwarded-headers")
|
||||
message = sdk.anthropic(key).messages.create(
|
||||
model=model,
|
||||
max_tokens=32,
|
||||
messages=[{"role": "user", "content": f"Reply with one word. {unique_marker()}"}],
|
||||
extra_headers={
|
||||
"x-forwarded-for": "10.11.232.194",
|
||||
"x-forwarded-port": "443",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-app": "cli",
|
||||
},
|
||||
extra_body=NO_PROXY_CACHE,
|
||||
)
|
||||
|
||||
assert message.role == "assistant", f"unexpected role: {message.role!r}"
|
||||
assert _message_text(message).strip(), f"forwarded-header response contained no text: {message.content!r}"
|
||||
|
|
@ -1,65 +1,24 @@
|
|||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from typing import Final, Protocol, cast
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from e2e_metadata import Domain, Subject, meta
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.secret_managers.aws_secret_manager_v2 import AWSSecretsManagerV2
|
||||
from litellm.secret_managers.main import get_secret
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
_AWS_FIXTURE_MASTER_KEY_SHA256: Final = "88dc28d0f030c55ed4ab77ed8faf098196cb1c05df778539800c9f1243fe6b4b"
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
_STRING_MAP: Final = TypeAdapter(dict[str, str])
|
||||
_STRING: Final = TypeAdapter(str)
|
||||
|
||||
|
||||
class _AWSSecretManagerOperations(Protocol):
|
||||
async def async_write_secret(self, secret_name: str, secret_value: str) -> object: ...
|
||||
|
||||
async def async_delete_secret(
|
||||
self, secret_name: str, recovery_window_in_days: int | None = 7
|
||||
) -> object: ...
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@meta(Subject(domain=Domain.DEPLOY_OPS))
|
||||
async def test_aws_secret_manager() -> None:
|
||||
def test_aws_secret_manager() -> None:
|
||||
AWSSecretsManagerV2.load_aws_secret_manager(use_aws_secret_manager=True)
|
||||
|
||||
fixture_value: Final[str | None] = cast(
|
||||
str | None, TypeAdapter(str | None).validate_python(get_secret("litellm_master_key"))
|
||||
secret_value: Final = _STRING_MAP.validate_json(get_secret("litellm_master_key"))
|
||||
assert (
|
||||
hashlib.sha256(secret_value["litellm_master_key"].encode()).hexdigest()
|
||||
== _AWS_FIXTURE_MASTER_KEY_SHA256
|
||||
)
|
||||
if fixture_value is not None:
|
||||
fixture_data: Final = _STRING_MAP.validate_json(_STRING.validate_python(fixture_value))
|
||||
assert hashlib.sha256(fixture_data["litellm_master_key"].encode()).hexdigest() == (
|
||||
_AWS_FIXTURE_MASTER_KEY_SHA256
|
||||
)
|
||||
return
|
||||
|
||||
secret_name: Final = f"litellm-e2e-secret-manager-{uuid.uuid4().hex}"
|
||||
secret_value: Final = f"sk-litellm-e2e-{uuid.uuid4().hex}"
|
||||
manager: Final = litellm.secret_manager_client
|
||||
assert isinstance(manager, AWSSecretsManagerV2)
|
||||
secret_manager: Final = cast(_AWSSecretManagerOperations, manager)
|
||||
try:
|
||||
creation: Final = _JSON_OBJECT.validate_python(
|
||||
await secret_manager.async_write_secret(
|
||||
secret_name,
|
||||
json.dumps({"litellm_master_key": secret_value}),
|
||||
)
|
||||
)
|
||||
assert creation.get("Name") == secret_name
|
||||
created_value: Final = _STRING.validate_python(get_secret(secret_name))
|
||||
created_data: Final = _STRING_MAP.validate_json(created_value)
|
||||
assert created_data["litellm_master_key"] == secret_value
|
||||
finally:
|
||||
deletion: Final = _JSON_OBJECT.validate_python(
|
||||
await secret_manager.async_delete_secret(secret_name, recovery_window_in_days=7)
|
||||
)
|
||||
assert deletion.get("Name") == secret_name
|
||||
|
|
|
|||
|
|
@ -19,5 +19,5 @@ async def test_2_never_inherits_the_pending_event() -> None:
|
|||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
|
||||
queued_on, ran_on = RUNS.get_nowait()
|
||||
assert RUNS.empty()
|
||||
assert ran_on is queued_on
|
||||
assert ran_on is not asyncio.get_running_loop()
|
||||
assert queued_on.is_closed()
|
||||
assert ran_on is asyncio.get_running_loop()
|
||||
|
|
@ -6,6 +6,8 @@ from tests.test_litellm_rust.support.child_interpreter import run_child_interpre
|
|||
|
||||
CANARY_MODULE: Final = Path(__file__).with_name("logging_worker_drain_canary.py")
|
||||
CANARY_RUN: Final = (
|
||||
"import sys\n"
|
||||
f"sys.path.insert(0, {str(Path(__file__).parents[2])!r})\n"
|
||||
"import pytest\n"
|
||||
f"raise SystemExit(pytest.main([{str(CANARY_MODULE)!r}, '-p', 'no:xdist', '-p', 'no:cacheprovider', '-q']))\n"
|
||||
)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
import pytest
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import BadRequestError
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.utils import CustomStreamWrapper
|
||||
|
||||
# test_example.py
|
||||
from abc import ABC
|
||||
|
||||
|
||||
class BaseLoggingCallbackTest(ABC):
|
||||
"""
|
||||
Abstract base test class that enforces a common test across all test classes.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response_obj(self):
|
||||
from litellm.types.utils import (
|
||||
ModelResponse,
|
||||
Choices,
|
||||
Message,
|
||||
ChatCompletionMessageToolCall,
|
||||
Function,
|
||||
Usage,
|
||||
CompletionTokensDetailsWrapper,
|
||||
PromptTokensDetailsWrapper,
|
||||
)
|
||||
|
||||
# Create a mock response object with the structure you need
|
||||
return ModelResponse(
|
||||
id="chatcmpl-ASId3YJWagBpBskWfoNEMPFSkmrEw",
|
||||
created=1731308157,
|
||||
model="gpt-4o-mini-2024-07-18",
|
||||
object="chat.completion",
|
||||
system_fingerprint="fp_0ba0d124f1",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="tool_calls",
|
||||
index=0,
|
||||
message=Message(
|
||||
content=None,
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
function=Function(
|
||||
arguments='{"city": "New York"}', name="get_weather"
|
||||
),
|
||||
id="call_PngsQS5YGmIZKnswhnUOnOVb",
|
||||
type="function",
|
||||
),
|
||||
ChatCompletionMessageToolCall(
|
||||
function=Function(
|
||||
arguments='{"city": "New York"}', name="get_news"
|
||||
),
|
||||
id="call_1zsDThBu0VSK7KuY7eCcJBnq",
|
||||
type="function",
|
||||
),
|
||||
],
|
||||
function_call=None,
|
||||
),
|
||||
)
|
||||
],
|
||||
usage=Usage(
|
||||
completion_tokens=46,
|
||||
prompt_tokens=86,
|
||||
total_tokens=132,
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(
|
||||
accepted_prediction_tokens=0,
|
||||
audio_tokens=0,
|
||||
reasoning_tokens=0,
|
||||
rejected_prediction_tokens=0,
|
||||
text_tokens=None,
|
||||
),
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
audio_tokens=0, cached_tokens=0, text_tokens=None, image_tokens=None
|
||||
),
|
||||
),
|
||||
service_tier=None,
|
||||
)
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
# conftest.py
|
||||
#
|
||||
# xdist-compatible test isolation for logging callback tests.
|
||||
#
|
||||
# Key design: capture litellm's true default values at conftest import time
|
||||
# (BEFORE test modules are imported) so we can reset to clean defaults before
|
||||
# each test. This is necessary because some test modules set module-level
|
||||
# globals like `litellm.num_retries = 3` which pollute state for all tests
|
||||
# in the same xdist worker.
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
import litellm
|
||||
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
from tests._vcr_conftest_common import ( # noqa: E402,F401
|
||||
VerboseReporterState,
|
||||
_pin_multipart_boundary,
|
||||
apply_vcr_auto_marker_to_items,
|
||||
emit_cassette_cache_session_banner,
|
||||
emit_vcr_classification_summary,
|
||||
emit_vcr_diagnostic_log,
|
||||
install_live_call_probe,
|
||||
record_vcr_outcome,
|
||||
register_persister_if_enabled,
|
||||
reset_vcr_diag_dir,
|
||||
vcr_config_dict,
|
||||
)
|
||||
|
||||
# vcrpy and respx both patch the httpx transport — applying both makes one
|
||||
# silently win, so respx-using files opt out of the auto-marker.
|
||||
_RESPX_CONFLICTING_FILES = frozenset(
|
||||
{
|
||||
"test_assemble_streaming_responses.py",
|
||||
"test_langfuse_unit_tests.py",
|
||||
}
|
||||
)
|
||||
|
||||
_VCR_INCOMPATIBLE_FILES = frozenset()
|
||||
|
||||
_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ()
|
||||
|
||||
|
||||
_verbose_state = VerboseReporterState()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vcr_config():
|
||||
return vcr_config_dict()
|
||||
|
||||
|
||||
def pytest_recording_configure(config, vcr):
|
||||
register_persister_if_enabled(vcr)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
outcome = yield
|
||||
rep = outcome.get_result()
|
||||
setattr(item, f"rep_{rep.when}", rep)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _vcr_outcome_gate(request, vcr):
|
||||
install_live_call_probe(request, vcr)
|
||||
yield
|
||||
record_vcr_outcome(request, vcr)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
_verbose_state.remember_pluginmanager(config)
|
||||
reset_vcr_diag_dir()
|
||||
|
||||
|
||||
def pytest_runtest_logreport(report):
|
||||
_verbose_state.maybe_emit_verdict(report)
|
||||
|
||||
|
||||
_LIST_ATTRS = (
|
||||
"callbacks",
|
||||
"success_callback",
|
||||
"failure_callback",
|
||||
"_async_success_callback",
|
||||
"_async_failure_callback",
|
||||
"service_callback",
|
||||
"pre_call_rules",
|
||||
"post_call_rules",
|
||||
)
|
||||
|
||||
_SCALAR_ATTRS = (
|
||||
"set_verbose",
|
||||
"cache",
|
||||
"num_retries",
|
||||
"num_retries_per_request",
|
||||
"turn_off_message_logging",
|
||||
"redact_messages_in_exceptions",
|
||||
"redact_user_api_key_info",
|
||||
"s3_callback_params",
|
||||
"s3_audit_callback_params",
|
||||
"datadog_params",
|
||||
"vector_store_registry",
|
||||
)
|
||||
|
||||
# ---- Capture true defaults at conftest import time ----
|
||||
# This runs BEFORE any test modules are imported, so values are clean.
|
||||
_DEFAULTS: dict = {}
|
||||
for _attr in _LIST_ATTRS:
|
||||
if hasattr(litellm, _attr):
|
||||
_val = getattr(litellm, _attr)
|
||||
_DEFAULTS[_attr] = _val.copy() if isinstance(_val, list) else _val
|
||||
for _attr in _SCALAR_ATTRS:
|
||||
if hasattr(litellm, _attr):
|
||||
_DEFAULTS[_attr] = getattr(litellm, _attr)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def isolate_litellm_state():
|
||||
"""
|
||||
Per-function isolation fixture.
|
||||
|
||||
Resets litellm state to the true defaults captured at conftest import time,
|
||||
then restores after the test. This prevents module-level mutations (e.g.
|
||||
`litellm.num_retries = 3` at the top of test_langfuse_e2e_test.py) from
|
||||
leaking across tests within the same xdist worker.
|
||||
"""
|
||||
from litellm.litellm_core_utils import litellm_logging as ll_logging
|
||||
from litellm.proxy.management_helpers import audit_logs as ll_audit_logs
|
||||
|
||||
# Flush cache and clear internal logger instances before test
|
||||
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
# Clear cached logger instances (LangsmithLogger, SlackAlerting, etc.)
|
||||
ll_logging._in_memory_loggers.clear()
|
||||
ll_audit_logs._audit_log_callback_cache.clear()
|
||||
|
||||
# Reset ALL attrs to their true defaults before the test runs.
|
||||
# This undoes any module-level mutations from test file imports.
|
||||
for attr in _LIST_ATTRS:
|
||||
if attr in _DEFAULTS:
|
||||
default = _DEFAULTS[attr]
|
||||
setattr(
|
||||
litellm, attr, default.copy() if isinstance(default, list) else default
|
||||
)
|
||||
|
||||
for attr in _SCALAR_ATTRS:
|
||||
if attr in _DEFAULTS:
|
||||
setattr(litellm, attr, _DEFAULTS[attr])
|
||||
|
||||
yield
|
||||
|
||||
# Teardown: reset back to defaults again (belt-and-suspenders)
|
||||
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
ll_logging._in_memory_loggers.clear()
|
||||
ll_audit_logs._audit_log_callback_cache.clear()
|
||||
|
||||
for attr in _LIST_ATTRS:
|
||||
if attr in _DEFAULTS:
|
||||
default = _DEFAULTS[attr]
|
||||
setattr(
|
||||
litellm, attr, default.copy() if isinstance(default, list) else default
|
||||
)
|
||||
|
||||
for attr in _SCALAR_ATTRS:
|
||||
if attr in _DEFAULTS:
|
||||
setattr(litellm, attr, _DEFAULTS[attr])
|
||||
|
||||
|
||||
LOGGING_WORKER_DRAIN_TIMEOUT_SECONDS: Final = LOGGING_WORKER_MAX_TIME_PER_COROUTINE + 5.0
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(loop_scope="function", autouse=True)
|
||||
async def drain_logging_worker(isolate_litellm_state: None) -> AsyncIterator[None]:
|
||||
yield
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=LOGGING_WORKER_DRAIN_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_and_teardown():
|
||||
"""
|
||||
Module-scoped setup. Reloads litellm only in single-process mode
|
||||
(skipped under xdist to avoid cross-worker interference).
|
||||
"""
|
||||
|
||||
import litellm
|
||||
|
||||
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
|
||||
if worker_id is None:
|
||||
importlib.reload(litellm)
|
||||
|
||||
try:
|
||||
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
|
||||
import litellm.proxy.proxy_server
|
||||
|
||||
importlib.reload(litellm.proxy.proxy_server)
|
||||
except Exception as e:
|
||||
print(f"Error reloading litellm.proxy.proxy_server: {e}")
|
||||
|
||||
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
yield
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
apply_vcr_auto_marker_to_items(
|
||||
items,
|
||||
skip_files=_RESPX_CONFLICTING_FILES | _VCR_INCOMPATIBLE_FILES,
|
||||
skip_nodeid_suffixes=_VCR_INCOMPATIBLE_NODEID_SUFFIXES,
|
||||
)
|
||||
|
||||
# Separate tests in 'test_amazing_proxy_custom_logger.py' and other tests
|
||||
custom_logger_tests = [
|
||||
item for item in items if "custom_logger" in item.parent.name
|
||||
]
|
||||
other_tests = [item for item in items if "custom_logger" not in item.parent.name]
|
||||
|
||||
# Sort tests based on their names
|
||||
custom_logger_tests.sort(key=lambda x: x.name)
|
||||
other_tests.sort(key=lambda x: x.name)
|
||||
|
||||
# Reorder the items list
|
||||
items[:] = custom_logger_tests + other_tests
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
||||
emit_cassette_cache_session_banner(terminalreporter)
|
||||
emit_vcr_classification_summary(terminalreporter)
|
||||
emit_vcr_diagnostic_log(terminalreporter)
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import io
|
||||
|
||||
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm import completion
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.datadog.datadog import *
|
||||
from datetime import datetime, timedelta
|
||||
from litellm.types.utils import (
|
||||
StandardLoggingPayload,
|
||||
StandardLoggingModelInformation,
|
||||
StandardLoggingMetadata,
|
||||
StandardLoggingHiddenParams,
|
||||
)
|
||||
|
||||
verbose_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
def create_standard_logging_payload() -> StandardLoggingPayload:
|
||||
return StandardLoggingPayload(
|
||||
id="test_id",
|
||||
call_type="completion",
|
||||
response_cost=0.1,
|
||||
response_cost_failure_debug_info=None,
|
||||
status="success",
|
||||
total_tokens=30,
|
||||
prompt_tokens=20,
|
||||
completion_tokens=10,
|
||||
startTime=1234567890.0,
|
||||
endTime=1234567891.0,
|
||||
completionStartTime=1234567890.5,
|
||||
model_map_information=StandardLoggingModelInformation(
|
||||
model_map_key="gpt-5-mini", model_map_value=None
|
||||
),
|
||||
model="gpt-5-mini",
|
||||
model_id="model-123",
|
||||
model_group="openai-gpt",
|
||||
api_base="https://api.openai.com",
|
||||
metadata=StandardLoggingMetadata(
|
||||
user_api_key_hash="test_hash",
|
||||
user_api_key_org_id=None,
|
||||
user_api_key_alias="test_alias",
|
||||
user_api_key_team_id="test_team",
|
||||
user_api_key_user_id="test_user",
|
||||
user_api_key_team_alias="test_team_alias",
|
||||
spend_logs_metadata=None,
|
||||
requester_ip_address="127.0.0.1",
|
||||
requester_metadata=None,
|
||||
),
|
||||
cache_hit=False,
|
||||
cache_key=None,
|
||||
saved_cache_cost=0.0,
|
||||
request_tags=[],
|
||||
end_user=None,
|
||||
requester_ip_address="127.0.0.1",
|
||||
messages=[{"role": "user", "content": "Hello, world!"}],
|
||||
response={"choices": [{"message": {"content": "Hi there!"}}]},
|
||||
error_str=None,
|
||||
model_parameters={"stream": True},
|
||||
hidden_params=StandardLoggingHiddenParams(
|
||||
model_id="model-123",
|
||||
cache_key=None,
|
||||
api_base="https://api.openai.com",
|
||||
response_cost="0.1",
|
||||
additional_headers=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_standard_logging_payload_with_long_content() -> StandardLoggingPayload:
|
||||
return StandardLoggingPayload(
|
||||
id="test_id",
|
||||
call_type="completion",
|
||||
response_cost=0.1,
|
||||
response_cost_failure_debug_info=None,
|
||||
status="success",
|
||||
total_tokens=30,
|
||||
prompt_tokens=20,
|
||||
completion_tokens=10,
|
||||
startTime=1234567890.0,
|
||||
endTime=1234567891.0,
|
||||
completionStartTime=1234567890.5,
|
||||
model_map_information=StandardLoggingModelInformation(
|
||||
model_map_key="gpt-5-mini", model_map_value=None
|
||||
),
|
||||
model="gpt-5-mini",
|
||||
model_id="model-123",
|
||||
model_group="openai-gpt",
|
||||
api_base="https://api.openai.com",
|
||||
metadata=StandardLoggingMetadata(
|
||||
user_api_key_hash="test_hash",
|
||||
user_api_key_org_id=None,
|
||||
user_api_key_alias="test_alias",
|
||||
user_api_key_team_id="test_team",
|
||||
user_api_key_user_id="test_user",
|
||||
user_api_key_team_alias="test_team_alias",
|
||||
spend_logs_metadata=None,
|
||||
requester_ip_address="127.0.0.1",
|
||||
requester_metadata=None,
|
||||
),
|
||||
cache_hit=False,
|
||||
cache_key=None,
|
||||
saved_cache_cost=0.0,
|
||||
request_tags=[],
|
||||
end_user=None,
|
||||
requester_ip_address="127.0.0.1",
|
||||
messages=[{"role": "user", "content": "Hello, world!" * 80000}],
|
||||
response={"choices": [{"message": {"content": "Hi there!" * 80000}}]},
|
||||
error_str="error_str" * 80000,
|
||||
model_parameters={"stream": True},
|
||||
hidden_params=StandardLoggingHiddenParams(
|
||||
model_id="model-123",
|
||||
cache_key=None,
|
||||
api_base="https://api.openai.com",
|
||||
response_cost="0.1",
|
||||
additional_headers=None,
|
||||
),
|
||||
)
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
{
|
||||
"id": "chatcmpl-2299b6a2-82a3-465a-b47c-04e685a2227f",
|
||||
"trace_id": null,
|
||||
"call_type": "acompletion",
|
||||
"cache_hit": null,
|
||||
"stream": true,
|
||||
"status": "success",
|
||||
"custom_llm_provider": "openai",
|
||||
"saved_cache_cost": 0.0,
|
||||
"startTime": "2025-01-24 09:20:46.847371",
|
||||
"endTime": "2025-01-24 09:20:46.851954",
|
||||
"completionStartTime": "2025-01-24 09:20:46.851954",
|
||||
"response_time": 0.007394075393676758,
|
||||
"model": "gpt-4o",
|
||||
"metadata": {
|
||||
"user_api_key_hash": null,
|
||||
"user_api_key_alias": null,
|
||||
"user_api_key_team_id": null,
|
||||
"user_api_key_org_id": null,
|
||||
"user_api_key_user_id": null,
|
||||
"user_api_key_team_alias": null,
|
||||
"user_api_key_user_email": null,
|
||||
"spend_logs_metadata": null,
|
||||
"requester_ip_address": null,
|
||||
"requester_metadata": null,
|
||||
"user_api_key_end_user_id": null,
|
||||
"prompt_management_metadata": null,
|
||||
"applied_guardrails": [],
|
||||
"mcp_tool_call_metadata": null,
|
||||
"vector_store_request_metadata": null,
|
||||
"guardrail_information": null
|
||||
},
|
||||
"cache_key": null,
|
||||
"response_cost": 0.00022500000000000002,
|
||||
"total_tokens": 30,
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"request_tags": [],
|
||||
"end_user": "",
|
||||
"api_base": "",
|
||||
"model_group": "",
|
||||
"model_id": "",
|
||||
"requester_ip_address": null,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, world!"
|
||||
}
|
||||
],
|
||||
"response": {
|
||||
"id": "chatcmpl-2299b6a2-82a3-465a-b47c-04e685a2227f",
|
||||
"created": 1742855151,
|
||||
"model": "gpt-4o",
|
||||
"object": "chat.completion",
|
||||
"system_fingerprint": null,
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "hi",
|
||||
"role": "assistant",
|
||||
"tool_calls": null,
|
||||
"function_call": null,
|
||||
"provider_specific_fields": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"completion_tokens": 20,
|
||||
"prompt_tokens": 10,
|
||||
"total_tokens": 30,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"model_parameters": {},
|
||||
"hidden_params": {
|
||||
"model_id": null,
|
||||
"cache_key": null,
|
||||
"api_base": "https://api.openai.com",
|
||||
"response_cost": 0.00022500000000000002,
|
||||
"additional_headers": {},
|
||||
"litellm_overhead_time_ms": null,
|
||||
"batch_models": null,
|
||||
"litellm_model_name": "gpt-4o"
|
||||
},
|
||||
"model_map_information": {
|
||||
"model_map_key": "gpt-4o",
|
||||
"model_map_value": {
|
||||
"key": "gpt-4o",
|
||||
"max_tokens": 16384,
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 16384,
|
||||
"input_cost_per_token": 2.5e-06,
|
||||
"cache_creation_input_token_cost": null,
|
||||
"cache_read_input_token_cost": 1.25e-06,
|
||||
"input_cost_per_character": null,
|
||||
"input_cost_per_token_above_128k_tokens": null,
|
||||
"input_cost_per_query": null,
|
||||
"input_cost_per_second": null,
|
||||
"input_cost_per_audio_token": null,
|
||||
"input_cost_per_token_batches": 1.25e-06,
|
||||
"output_cost_per_token_batches": 5e-06,
|
||||
"output_cost_per_token": 1e-05,
|
||||
"output_cost_per_audio_token": null,
|
||||
"output_cost_per_character": null,
|
||||
"output_cost_per_token_above_128k_tokens": null,
|
||||
"output_cost_per_character_above_128k_tokens": null,
|
||||
"output_cost_per_second": null,
|
||||
"output_cost_per_image": null,
|
||||
"output_vector_size": null,
|
||||
"litellm_provider": "openai",
|
||||
"mode": "chat",
|
||||
"supports_system_messages": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_vision": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_audio_input": false,
|
||||
"supports_audio_output": false,
|
||||
"supports_pdf_input": false,
|
||||
"supports_embedding_image_input": false,
|
||||
"supports_native_streaming": null,
|
||||
"supports_web_search": true,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_low": 0.03,
|
||||
"search_context_size_medium": 0.035,
|
||||
"search_context_size_high": 0.05
|
||||
},
|
||||
"tpm": null,
|
||||
"rpm": null,
|
||||
"supported_openai_params": [
|
||||
"frequency_penalty",
|
||||
"logit_bias",
|
||||
"logprobs",
|
||||
"top_logprobs",
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"modalities",
|
||||
"prediction",
|
||||
"n",
|
||||
"presence_penalty",
|
||||
"seed",
|
||||
"stop",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"function_call",
|
||||
"functions",
|
||||
"max_retries",
|
||||
"extra_headers",
|
||||
"parallel_tool_calls",
|
||||
"audio",
|
||||
"response_format",
|
||||
"user"
|
||||
]
|
||||
}
|
||||
},
|
||||
"error_str": null,
|
||||
"error_information": {
|
||||
"error_code": "",
|
||||
"error_class": "",
|
||||
"llm_provider": "",
|
||||
"traceback": "",
|
||||
"error_message": ""
|
||||
},
|
||||
"response_cost_failure_debug_info": null,
|
||||
"guardrail_information": null,
|
||||
"standard_built_in_tools_params": {
|
||||
"web_search_options": null,
|
||||
"file_search": null
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,21 +0,0 @@
|
|||
============================= test session starts ==============================
|
||||
platform darwin -- Python 3.13.1, pytest-8.3.5, pluggy-1.5.0
|
||||
rootdir: /Users/krrishdholakia/Documents/litellm
|
||||
configfile: pyproject.toml
|
||||
plugins: respx-0.22.0, postgresql-7.0.1, anyio-4.4.0, asyncio-0.26.0, mock-3.14.0, ddtrace-2.19.0rc1
|
||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collected 1 item
|
||||
|
||||
test_gcs_pub_sub.py . [100%]
|
||||
|
||||
=============================== warnings summary ===============================
|
||||
../../myenv/lib/python3.13/site-packages/pydantic/_internal/_config.py:295
|
||||
/Users/krrishdholakia/Documents/litellm/myenv/lib/python3.13/site-packages/pydantic/_internal/_config.py:295: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.10/migration/
|
||||
warnings.warn(DEPRECATION_MESSAGE, DeprecationWarning)
|
||||
|
||||
../../litellm/caching/llm_caching_handler.py:17
|
||||
/Users/krrishdholakia/Documents/litellm/litellm/caching/llm_caching_handler.py:17: DeprecationWarning: There is no current event loop
|
||||
event_loop = asyncio.get_event_loop()
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
======================== 1 passed, 2 warnings in 4.10s =========================
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
import os
|
||||
|
||||
|
||||
|
||||
import litellm
|
||||
import litellm.vector_stores.main
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.types.utils import (
|
||||
StandardLoggingPayload,
|
||||
)
|
||||
|
||||
|
||||
class MockCustomLogger(CustomLogger):
|
||||
def __init__(self):
|
||||
self.standard_logging_payload: Optional[StandardLoggingPayload] = None
|
||||
self.completion_logging_payload: Optional[StandardLoggingPayload] = None
|
||||
super().__init__()
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
payload = kwargs.get("standard_logging_object")
|
||||
# Store the payload - completion calls have call_type='acompletion'
|
||||
if payload and payload.get("call_type") == "acompletion":
|
||||
self.completion_logging_payload = payload
|
||||
self.standard_logging_payload = payload
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def add_aws_region_to_env(monkeypatch):
|
||||
monkeypatch.setenv("AWS_REGION", "us-west-2")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_vector_store_registry():
|
||||
from litellm.vector_stores.vector_store_registry import (
|
||||
VectorStoreRegistry,
|
||||
LiteLLM_ManagedVectorStore,
|
||||
)
|
||||
|
||||
# Init vector store registry
|
||||
litellm.vector_store_registry = VectorStoreRegistry(
|
||||
vector_stores=[
|
||||
LiteLLM_ManagedVectorStore(
|
||||
vector_store_id="T37J8R4WTM", custom_llm_provider="bedrock"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bedrock_knowledgebase_retrieval_with_llm_api_call(
|
||||
setup_vector_store_registry,
|
||||
):
|
||||
"""
|
||||
Test that the Bedrock Knowledge Base Hook works when making a real llm api call and returns citations.
|
||||
"""
|
||||
|
||||
# Init client
|
||||
litellm.turn_on_debug()
|
||||
async_client = AsyncHTTPHandler()
|
||||
response = await litellm.acompletion(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[{"role": "user", "content": "what is litellm?"}],
|
||||
vector_store_ids=["T37J8R4WTM"],
|
||||
client=async_client,
|
||||
)
|
||||
print("OPENAI RESPONSE:", json.dumps(dict(response), indent=4, default=str))
|
||||
assert response is not None
|
||||
|
||||
# Check that search_results are present in provider_specific_fields
|
||||
assert hasattr(response.choices[0].message, "provider_specific_fields")
|
||||
provider_fields = response.choices[0].message.provider_specific_fields
|
||||
assert provider_fields is not None
|
||||
assert "search_results" in provider_fields
|
||||
search_results = provider_fields["search_results"]
|
||||
assert search_results is not None
|
||||
assert len(search_results) > 0
|
||||
|
||||
# Check search result structure (OpenAI-compatible format)
|
||||
first_search_result = search_results[0]
|
||||
assert "object" in first_search_result
|
||||
assert first_search_result["object"] == "vector_store.search_results.page"
|
||||
assert "data" in first_search_result
|
||||
assert len(first_search_result["data"]) > 0
|
||||
|
||||
# Check individual result structure
|
||||
first_result = first_search_result["data"][0]
|
||||
assert "score" in first_result
|
||||
assert "content" in first_result
|
||||
print(f"Search results returned: {len(search_results)}")
|
||||
print(f"First search result has {len(first_search_result['data'])} items")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bedrock_knowledgebase_retrieval_with_llm_api_call_with_tools(
|
||||
setup_vector_store_registry,
|
||||
):
|
||||
"""
|
||||
Test that the Bedrock Knowledge Base Hook works when making a real llm api call
|
||||
"""
|
||||
|
||||
# Init client
|
||||
litellm.turn_on_debug()
|
||||
response = await litellm.acompletion(
|
||||
model=f"anthropic/{os.environ.get('CI_CD_DEFAULT_ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001')}",
|
||||
messages=[{"role": "user", "content": "what is litellm?"}],
|
||||
max_tokens=10,
|
||||
tools=[{"type": "file_search", "vector_store_ids": ["T37J8R4WTM"]}],
|
||||
)
|
||||
assert response is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bedrock_knowledgebase_retrieval_with_llm_api_call_with_tools_and_filters(
|
||||
setup_vector_store_registry,
|
||||
):
|
||||
"""
|
||||
Test that filters from file_search tools are properly passed through to vector store search.
|
||||
This test verifies the entire flow: tool parsing -> filter extraction -> vector store API call.
|
||||
|
||||
In this case we filter for a non-existent user_id, which should return no results.
|
||||
"""
|
||||
litellm.turn_on_debug()
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=f"anthropic/{os.environ.get('CI_CD_DEFAULT_ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001')}",
|
||||
messages=[{"role": "user", "content": "what is litellm?"}],
|
||||
max_tokens=10,
|
||||
tools=[
|
||||
{
|
||||
"type": "file_search",
|
||||
"vector_store_ids": ["T37J8R4WTM"],
|
||||
"filters": {
|
||||
"key": "user_id",
|
||||
"value": "fake-user-id",
|
||||
"operator": "eq",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
# Verify response is not None
|
||||
assert response is not None
|
||||
|
||||
# Verify search results were added to the response (this proves the search was called)
|
||||
assert hasattr(response.choices[0].message, "provider_specific_fields")
|
||||
provider_fields = response.choices[0].message.provider_specific_fields
|
||||
assert provider_fields is not None
|
||||
assert (
|
||||
"search_results" in provider_fields
|
||||
), "search_results not in provider_specific_fields"
|
||||
|
||||
search_results = provider_fields["search_results"]
|
||||
assert (
|
||||
search_results is not None and len(search_results) > 0
|
||||
), "No search results found"
|
||||
|
||||
# The search was performed - this confirms filters were passed through
|
||||
# The logs above show: litellm.asearch(... filters={'key': 'user_id', 'value': 'fake-user-id', 'operator': 'eq'})
|
||||
# And the Bedrock API request contains: {'filter': {'equals': {'key': 'user_id', 'value': 'fake-user-id'}}}
|
||||
|
||||
print("✅ Filters were successfully passed through to vector store search")
|
||||
print(f" Search was performed and {len(search_results)} result(s) returned")
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_logging_with_knowledge_base_hook(setup_vector_store_registry):
|
||||
# """
|
||||
# Test that the knowledge base request was logged in standard logging payload
|
||||
# """
|
||||
# test_custom_logger = MockCustomLogger()
|
||||
# litellm.set_verbose = True
|
||||
# await litellm.acompletion(
|
||||
# model="gpt-5.5",
|
||||
# messages=[{"role": "user", "content": "what is litellm?"}],
|
||||
# vector_store_ids = [
|
||||
# "T37J8R4WTM"
|
||||
# ],
|
||||
# )
|
||||
|
||||
# # sleep for 1 second to allow the logging callback to run
|
||||
# await asyncio.sleep(1)
|
||||
|
||||
# # assert that the knowledge base request was logged in the standard logging payload
|
||||
# standard_logging_payload: Optional[StandardLoggingPayload] = test_custom_logger.standard_logging_payload
|
||||
# assert standard_logging_payload is not None
|
||||
|
||||
|
||||
# metadata = standard_logging_payload["metadata"]
|
||||
# standard_logging_vector_store_request_metadata: Optional[List[StandardLoggingVectorStoreRequest]] = metadata["vector_store_request_metadata"]
|
||||
|
||||
# print("standard_logging_vector_store_request_metadata:", json.dumps(standard_logging_vector_store_request_metadata, indent=4, default=str))
|
||||
|
||||
# # 1 vector store request was made, expect 1 vector store request metadata object
|
||||
# assert len(standard_logging_vector_store_request_metadata) == 1
|
||||
|
||||
# # expect the vector store request metadata object to have the correct values
|
||||
# vector_store_request_metadata = standard_logging_vector_store_request_metadata[0]
|
||||
# assert vector_store_request_metadata.get("vector_store_id") == "T37J8R4WTM"
|
||||
# assert vector_store_request_metadata.get("query") == "what is litellm?"
|
||||
# assert vector_store_request_metadata.get("custom_llm_provider") == "bedrock"
|
||||
|
||||
|
||||
# vector_store_search_response: VectorStoreSearchResponse = vector_store_request_metadata.get("vector_store_search_response")
|
||||
# assert vector_store_search_response is not None
|
||||
# assert vector_store_search_response.get("search_query") == "what is litellm?"
|
||||
# assert len(vector_store_search_response.get("data", [])) >=0
|
||||
# for item in vector_store_search_response.get("data", []):
|
||||
# assert item.get("score") is not None
|
||||
# assert item.get("content") is not None
|
||||
# assert len(item.get("content", [])) >= 0
|
||||
# for content_item in item.get("content", []):
|
||||
# text_content = content_item.get("text")
|
||||
# assert text_content is not None
|
||||
# assert len(text_content) > 0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
import io
|
||||
import os
|
||||
|
||||
|
||||
|
||||
import asyncio
|
||||
import litellm
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm import completion
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.gcs_pubsub.pub_sub import *
|
||||
from datetime import datetime, timedelta
|
||||
from litellm.types.utils import (
|
||||
StandardLoggingPayload,
|
||||
StandardLoggingModelInformation,
|
||||
StandardLoggingMetadata,
|
||||
StandardLoggingHiddenParams,
|
||||
)
|
||||
|
||||
verbose_logger.setLevel(logging.DEBUG)
|
||||
|
||||
ignored_keys = [
|
||||
"request_id",
|
||||
"litellm_call_id",
|
||||
"metadata.litellm_call_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"completionStartTime",
|
||||
"endTime",
|
||||
"request_duration_ms",
|
||||
"metadata.model_map_information",
|
||||
"metadata.usage_object",
|
||||
"metadata.cold_storage_object_key",
|
||||
"metadata.litellm_overhead_time_ms",
|
||||
"metadata.cost_breakdown",
|
||||
"metadata.autorouter_savings",
|
||||
"metadata.eval_information",
|
||||
]
|
||||
|
||||
|
||||
def _compare_nested_dicts(
|
||||
actual: dict, expected: dict, path: str = "", ignore_keys: list[str] = []
|
||||
) -> list[str]:
|
||||
"""Compare nested dictionaries and return a list of differences in a human-friendly format."""
|
||||
differences = []
|
||||
|
||||
# Check if current path should be ignored
|
||||
if path in ignore_keys:
|
||||
return differences
|
||||
|
||||
# Check for keys in actual but not in expected
|
||||
for key in actual.keys():
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
if current_path not in ignore_keys and key not in expected:
|
||||
differences.append(f"Extra key in actual: {current_path}")
|
||||
|
||||
for key, expected_value in expected.items():
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
if current_path in ignore_keys:
|
||||
continue
|
||||
if key not in actual:
|
||||
differences.append(f"Missing key: {current_path}")
|
||||
continue
|
||||
|
||||
actual_value = actual[key]
|
||||
|
||||
# Try to parse JSON strings
|
||||
if isinstance(expected_value, str):
|
||||
try:
|
||||
expected_value = json.loads(expected_value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if isinstance(actual_value, str):
|
||||
try:
|
||||
actual_value = json.loads(actual_value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if isinstance(expected_value, dict) and isinstance(actual_value, dict):
|
||||
differences.extend(
|
||||
_compare_nested_dicts(
|
||||
actual_value, expected_value, current_path, ignore_keys
|
||||
)
|
||||
)
|
||||
elif isinstance(expected_value, dict) or isinstance(actual_value, dict):
|
||||
differences.append(
|
||||
f"Type mismatch at {current_path}: expected dict, got {type(actual_value).__name__}"
|
||||
)
|
||||
else:
|
||||
# For non-dict values, only report if they're different
|
||||
if actual_value != expected_value:
|
||||
# Format the values to be more readable
|
||||
actual_str = str(actual_value)
|
||||
expected_str = str(expected_value)
|
||||
if len(actual_str) > 50 or len(expected_str) > 50:
|
||||
actual_str = f"{actual_str[:50]}..."
|
||||
expected_str = f"{expected_str[:50]}..."
|
||||
differences.append(
|
||||
f"Value mismatch at {current_path}:\n expected: {expected_str}\n got: {actual_str}"
|
||||
)
|
||||
return differences
|
||||
|
||||
|
||||
def assert_gcs_pubsub_request_matches_expected(
|
||||
actual_request_body: dict,
|
||||
expected_file_name: str,
|
||||
):
|
||||
"""
|
||||
Helper function to compare actual GCS PubSub request body with expected JSON file.
|
||||
|
||||
Args:
|
||||
actual_request_body (dict): The actual request body received from the API call
|
||||
expected_file_name (str): Name of the JSON file containing expected request body
|
||||
"""
|
||||
# Get the current directory and read the expected request body
|
||||
pwd = os.path.dirname(os.path.realpath(__file__))
|
||||
expected_body_path = os.path.join(pwd, "gcs_pub_sub_body", expected_file_name)
|
||||
|
||||
with open(expected_body_path, "r") as f:
|
||||
expected_request_body = json.load(f)
|
||||
|
||||
# Replace dynamic values in actual request body
|
||||
differences = _compare_nested_dicts(
|
||||
actual_request_body, expected_request_body, ignore_keys=ignored_keys
|
||||
)
|
||||
if differences:
|
||||
pytest.fail(f"Dictionary mismatch: {differences}")
|
||||
|
||||
|
||||
def assert_gcs_pubsub_request_matches_expected_standard_logging_payload(
|
||||
actual_request_body: dict,
|
||||
expected_file_name: str,
|
||||
):
|
||||
"""
|
||||
Helper function to compare actual GCS PubSub request body with expected JSON file.
|
||||
|
||||
Args:
|
||||
actual_request_body (dict): The actual request body received from the API call
|
||||
expected_file_name (str): Name of the JSON file containing expected request body
|
||||
"""
|
||||
# Get the current directory and read the expected request body
|
||||
pwd = os.path.dirname(os.path.realpath(__file__))
|
||||
expected_body_path = os.path.join(pwd, "gcs_pub_sub_body", expected_file_name)
|
||||
|
||||
with open(expected_body_path, "r") as f:
|
||||
expected_request_body = json.load(f)
|
||||
|
||||
# Replace dynamic values in actual request body
|
||||
FIELDS_TO_VALIDATE = [
|
||||
"custom_llm_provider",
|
||||
"hidden_params",
|
||||
"messages",
|
||||
"response",
|
||||
"model",
|
||||
"status",
|
||||
"stream",
|
||||
]
|
||||
|
||||
actual_request_body["response"]["id"] = expected_request_body["response"]["id"]
|
||||
actual_request_body["response"]["created"] = expected_request_body["response"][
|
||||
"created"
|
||||
]
|
||||
|
||||
for field in FIELDS_TO_VALIDATE:
|
||||
assert field in actual_request_body
|
||||
|
||||
FIELDS_EXISTENCE_CHECKS = [
|
||||
"response_cost",
|
||||
"response_time",
|
||||
"completion_tokens",
|
||||
"prompt_tokens",
|
||||
"total_tokens",
|
||||
]
|
||||
|
||||
for field in FIELDS_EXISTENCE_CHECKS:
|
||||
assert field in actual_request_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_gcs_pub_sub():
|
||||
# Create a mock for the async_httpx_client's post method
|
||||
mock_post = AsyncMock()
|
||||
mock_post.return_value.status_code = 202
|
||||
mock_post.return_value.text = "Accepted"
|
||||
|
||||
# Initialize the GcsPubSubLogger and set the mock
|
||||
gcs_pub_sub_logger = GcsPubSubLogger(
|
||||
project_id="STUBBED_PROJECT_ID", topic_id="STUBBED_TOPIC_ID", flush_interval=1
|
||||
)
|
||||
gcs_pub_sub_logger.async_httpx_client.post = mock_post
|
||||
|
||||
mock_construct_request_headers = AsyncMock()
|
||||
mock_construct_request_headers.return_value = {"Authorization": "Bearer mock_token"}
|
||||
gcs_pub_sub_logger.construct_request_headers = mock_construct_request_headers
|
||||
litellm.callbacks = [gcs_pub_sub_logger]
|
||||
|
||||
# Make the completion call
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "Hello, world!"}],
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
await asyncio.sleep(3) # Wait for async flush
|
||||
|
||||
# Assert httpx post was called
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Get the actual request body from the mock
|
||||
actual_url = mock_post.call_args[1]["url"]
|
||||
print("sent to url", actual_url)
|
||||
assert (
|
||||
actual_url
|
||||
== "https://pubsub.googleapis.com/v1/projects/STUBBED_PROJECT_ID/topics/STUBBED_TOPIC_ID:publish"
|
||||
)
|
||||
actual_request = mock_post.call_args[1]["json"]
|
||||
|
||||
# Extract and decode the base64 encoded message
|
||||
encoded_message = actual_request["messages"][0]["data"]
|
||||
import base64
|
||||
|
||||
decoded_message = base64.b64decode(encoded_message).decode("utf-8")
|
||||
|
||||
# Parse the JSON string into a dictionary
|
||||
actual_request = json.loads(decoded_message)
|
||||
print("##########\n")
|
||||
print(json.dumps(actual_request, indent=4))
|
||||
print("##########\n")
|
||||
# Verify the request body matches expected format
|
||||
assert_gcs_pubsub_request_matches_expected_standard_logging_payload(
|
||||
actual_request, "standard_logging_payload.json"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_gcs_pub_sub_v1():
|
||||
# Create a mock for the async_httpx_client's post method
|
||||
litellm.gcs_pub_sub_use_v1 = True
|
||||
mock_post = AsyncMock()
|
||||
mock_post.return_value.status_code = 202
|
||||
mock_post.return_value.text = "Accepted"
|
||||
|
||||
# Initialize the GcsPubSubLogger and set the mock
|
||||
gcs_pub_sub_logger = GcsPubSubLogger(
|
||||
project_id="STUBBED_PROJECT_ID", topic_id="STUBBED_TOPIC_ID", flush_interval=1
|
||||
)
|
||||
gcs_pub_sub_logger.async_httpx_client.post = mock_post
|
||||
|
||||
mock_construct_request_headers = AsyncMock()
|
||||
mock_construct_request_headers.return_value = {"Authorization": "Bearer mock_token"}
|
||||
gcs_pub_sub_logger.construct_request_headers = mock_construct_request_headers
|
||||
litellm.callbacks = [gcs_pub_sub_logger]
|
||||
|
||||
# Make the completion call
|
||||
response = await litellm.acompletion(
|
||||
model="gpt-4o",
|
||||
messages=[{"role": "user", "content": "Hello, world!"}],
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
await asyncio.sleep(3) # Wait for async flush
|
||||
|
||||
# Assert httpx post was called
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Get the actual request body from the mock
|
||||
actual_url = mock_post.call_args[1]["url"]
|
||||
print("sent to url", actual_url)
|
||||
assert (
|
||||
actual_url
|
||||
== "https://pubsub.googleapis.com/v1/projects/STUBBED_PROJECT_ID/topics/STUBBED_TOPIC_ID:publish"
|
||||
)
|
||||
actual_request = mock_post.call_args[1]["json"]
|
||||
|
||||
# Extract and decode the base64 encoded message
|
||||
encoded_message = actual_request["messages"][0]["data"]
|
||||
import base64
|
||||
|
||||
decoded_message = base64.b64decode(encoded_message).decode("utf-8")
|
||||
|
||||
# Parse the JSON string into a dictionary
|
||||
actual_request = json.loads(decoded_message)
|
||||
print("##########\n")
|
||||
print(json.dumps(actual_request, indent=4))
|
||||
print("##########\n")
|
||||
# Verify the request body matches expected format
|
||||
assert_gcs_pubsub_request_matches_expected(
|
||||
actual_request, "spend_logs_payload.json"
|
||||
)
|
||||
|
|
@ -1,545 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
|
||||
from opentelemetry.proto.common.v1.common_pb2 import AnyValue
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.langfuse.langfuse_sdk import resolve_observation_id, resolve_trace_id
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
|
||||
litellm.num_retries = 3
|
||||
litellm.success_callback = ["langfuse"]
|
||||
os.environ["LANGFUSE_DEBUG"] = "True"
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
LANGFUSE_EXPORT_POST: Final = "litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
LANGFUSE_EXPORT_PATH: Final = "/api/public/otel/v1/traces"
|
||||
|
||||
_PER_RUN_ATTRIBUTES: Final = frozenset(
|
||||
{
|
||||
"langfuse.observation.completion_start_time",
|
||||
"langfuse.observation.metadata.applied_guardrails",
|
||||
"langfuse.observation.metadata.cache_hit",
|
||||
"langfuse.observation.metadata.hidden_params",
|
||||
"langfuse.observation.metadata.litellm_call_id",
|
||||
"langfuse.observation.metadata.litellm_response_cost",
|
||||
"langfuse.observation.metadata.requester_metadata",
|
||||
"langfuse.observation.metadata.response_id",
|
||||
"langfuse.observation.metadata.usage_object",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _decode_attribute(value: AnyValue) -> object:
|
||||
match value.WhichOneof("value"):
|
||||
case "string_value":
|
||||
try:
|
||||
return json.loads(value.string_value)
|
||||
except json.JSONDecodeError:
|
||||
return value.string_value
|
||||
case "bool_value":
|
||||
return value.bool_value
|
||||
case "int_value":
|
||||
return value.int_value
|
||||
case "double_value":
|
||||
return value.double_value
|
||||
case "array_value":
|
||||
return [_decode_attribute(item) for item in value.array_value.values]
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _exported_spans(mock_post: MagicMock) -> list[dict[str, object]]:
|
||||
spans: list[dict[str, object]] = []
|
||||
for call in mock_post.call_args_list:
|
||||
url: str = call.args[0] if call.args else call.kwargs["url"]
|
||||
assert url.endswith(LANGFUSE_EXPORT_PATH), url
|
||||
request = ExportTraceServiceRequest.FromString(call.kwargs["data"])
|
||||
for resource_spans in request.resource_spans:
|
||||
for scope_spans in resource_spans.scope_spans:
|
||||
for span in scope_spans.spans:
|
||||
spans.append(
|
||||
{
|
||||
"name": span.name,
|
||||
"trace_id": span.trace_id.hex(),
|
||||
"span_id": span.span_id.hex(),
|
||||
"parent_span_id": span.parent_span_id.hex() or None,
|
||||
"attributes": {
|
||||
attribute.key: _decode_attribute(attribute.value) for attribute in span.attributes
|
||||
},
|
||||
}
|
||||
)
|
||||
return spans
|
||||
|
||||
|
||||
def _comparable(span: Mapping[str, object]) -> dict[str, object]:
|
||||
attributes = span["attributes"]
|
||||
assert isinstance(attributes, dict)
|
||||
return {
|
||||
"name": span["name"],
|
||||
"parent_span_id": span["parent_span_id"],
|
||||
"attributes": {key: value for key, value in sorted(attributes.items()) if key not in _PER_RUN_ATTRIBUTES},
|
||||
}
|
||||
|
||||
|
||||
def assert_langfuse_request_matches_expected(
|
||||
spans: list[dict[str, object]],
|
||||
expected_file_name: str,
|
||||
trace_id: str,
|
||||
):
|
||||
"""Compare the generation langfuse exported for ``trace_id`` with the expected JSON file."""
|
||||
pwd = os.path.dirname(os.path.realpath(__file__))
|
||||
expected_body_path = os.path.join(pwd, "langfuse_expected_request_body", expected_file_name)
|
||||
with open(expected_body_path, "r") as f:
|
||||
expected_generation = json.load(f)
|
||||
|
||||
otel_trace_id: Final = resolve_trace_id(trace_id)
|
||||
generations: Final = [
|
||||
span
|
||||
for span in spans
|
||||
if span["trace_id"] == otel_trace_id and span["attributes"]["langfuse.observation.type"] == "generation" # pyright: ignore[reportIndexIssue] # built as dict in _exported_spans
|
||||
]
|
||||
assert len(generations) == 1, (
|
||||
f"Expected exactly one generation for trace_id={trace_id} ({otel_trace_id}), "
|
||||
f"got {len(generations)}. Spans: {json.dumps(spans, indent=2)}"
|
||||
)
|
||||
|
||||
actual_generation: Final = _comparable(generations[0])
|
||||
assert actual_generation == expected_generation, (
|
||||
f"Difference in exported generation: {json.dumps(actual_generation, indent=2)} "
|
||||
f"!= {json.dumps(expected_generation, indent=2)}"
|
||||
)
|
||||
|
||||
|
||||
class TestLangfuseLogging:
|
||||
@pytest_asyncio.fixture
|
||||
async def mock_setup(self):
|
||||
"""Common setup for Langfuse logging tests"""
|
||||
from litellm._uuid import uuid
|
||||
|
||||
mock_post = MagicMock(return_value=MagicMock(ok=True, status_code=200))
|
||||
|
||||
litellm.set_verbose = True
|
||||
litellm.success_callback = ["langfuse"]
|
||||
|
||||
return {"trace_id": f"litellm-test-{uuid.uuid4()!s}", "mock_post": mock_post}
|
||||
|
||||
async def _verify_langfuse_call(
|
||||
self,
|
||||
mock_post,
|
||||
expected_file_name: str,
|
||||
trace_id: str,
|
||||
):
|
||||
"""Wait for the batch processor to export, then compare the generation it shipped."""
|
||||
otel_trace_id: Final = resolve_trace_id(trace_id)
|
||||
for _ in range(100):
|
||||
if any(span["trace_id"] == otel_trace_id for span in _exported_spans(mock_post)):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert mock_post.call_count >= 1, "langfuse exported nothing"
|
||||
assert_langfuse_request_matches_expected(
|
||||
_exported_spans(mock_post),
|
||||
expected_file_name,
|
||||
trace_id,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion"""
|
||||
setup = mock_setup
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={"trace_id": setup["trace_id"]},
|
||||
)
|
||||
await self._verify_langfuse_call(setup["mock_post"], "completion.json", setup["trace_id"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_tags(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with tags"""
|
||||
setup = mock_setup
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={
|
||||
"trace_id": setup["trace_id"],
|
||||
"tags": ["test_tag", "test_tag_2"],
|
||||
},
|
||||
)
|
||||
await self._verify_langfuse_call(setup["mock_post"], "completion_with_tags.json", setup["trace_id"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_tags_stream(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with tags"""
|
||||
setup = mock_setup
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={
|
||||
"trace_id": setup["trace_id"],
|
||||
"tags": ["test_tag_stream", "test_tag_2_stream"],
|
||||
},
|
||||
)
|
||||
await self._verify_langfuse_call(
|
||||
setup["mock_post"],
|
||||
"completion_with_tags_stream.json",
|
||||
setup["trace_id"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_generation_id_metadata_names_the_exported_observation(self, mock_setup):
|
||||
"""v2 let callers pick the generation id; v4 only has span ids, so the requested id must become one."""
|
||||
setup = mock_setup
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={"trace_id": setup["trace_id"], "generation_id": "my-generation"},
|
||||
)
|
||||
await self._verify_langfuse_call(setup["mock_post"], "completion.json", setup["trace_id"])
|
||||
|
||||
generation: Final = next(
|
||||
span
|
||||
for span in _exported_spans(setup["mock_post"])
|
||||
if span["trace_id"] == resolve_trace_id(setup["trace_id"])
|
||||
)
|
||||
assert generation["span_id"] == resolve_observation_id("my-generation")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_langfuse_metadata(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with metadata for langfuse"""
|
||||
setup = mock_setup
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={
|
||||
"trace_id": setup["trace_id"],
|
||||
"tags": ["test_tag", "test_tag_2"],
|
||||
"generation_name": "test_generation_name",
|
||||
"parent_observation_id": "test_parent_observation_id",
|
||||
"version": "test_version",
|
||||
"trace_user_id": "test_user_id",
|
||||
"session_id": "test_session_id",
|
||||
"trace_name": "test_trace_name",
|
||||
"trace_metadata": {"test_key": "test_value"},
|
||||
"trace_version": "test_trace_version",
|
||||
"trace_release": "test_trace_release",
|
||||
},
|
||||
)
|
||||
await self._verify_langfuse_call(
|
||||
setup["mock_post"],
|
||||
"completion_with_langfuse_metadata.json",
|
||||
setup["trace_id"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_with_non_serializable_metadata(self, mock_setup):
|
||||
"""Test Langfuse logging with metadata that requires preparation (Pydantic models, sets, etc)"""
|
||||
import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
favorite_colors: set[str]
|
||||
last_login: datetime.datetime
|
||||
settings: dict
|
||||
|
||||
setup = mock_setup
|
||||
|
||||
test_metadata = {
|
||||
"user_prefs": UserPreferences(
|
||||
favorite_colors={"red", "blue"},
|
||||
last_login=datetime.datetime.now(),
|
||||
settings={"theme": "dark", "notifications": True},
|
||||
),
|
||||
"nested_set": {
|
||||
"inner_set": {1, 2, 3},
|
||||
"inner_pydantic": UserPreferences(
|
||||
favorite_colors={"green", "yellow"},
|
||||
last_login=datetime.datetime.now(),
|
||||
settings={"theme": "light"},
|
||||
),
|
||||
},
|
||||
"trace_id": setup["trace_id"],
|
||||
}
|
||||
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata=test_metadata,
|
||||
)
|
||||
|
||||
await self._verify_langfuse_call(
|
||||
setup["mock_post"],
|
||||
"completion_with_complex_metadata.json",
|
||||
setup["trace_id"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"test_metadata, response_json_file",
|
||||
[
|
||||
({"a": 1, "b": 2, "c": 3}, "simple_metadata.json"),
|
||||
(
|
||||
{"a": {"nested_a": 1}, "b": {"nested_b": 2}},
|
||||
"nested_metadata.json",
|
||||
),
|
||||
({"a": [1, 2, 3], "b": {4, 5, 6}}, "simple_metadata2.json"),
|
||||
(
|
||||
{"a": (1, 2), "b": frozenset([3, 4]), "c": {"d": [5, 6]}},
|
||||
"simple_metadata3.json",
|
||||
),
|
||||
({"lock": threading.Lock()}, "metadata_with_lock.json"),
|
||||
({"func": lambda x: x + 1}, "metadata_with_function.json"),
|
||||
(
|
||||
{
|
||||
"int": 42,
|
||||
"str": "hello",
|
||||
"list": [1, 2, 3],
|
||||
"set": {4, 5},
|
||||
"dict": {"nested": "value"},
|
||||
"non_copyable": threading.Lock(),
|
||||
"function": print,
|
||||
},
|
||||
"complex_metadata.json",
|
||||
),
|
||||
(
|
||||
{"list": ["list", "not", "a", "dict"]},
|
||||
"complex_metadata_2.json",
|
||||
),
|
||||
({}, "empty_metadata.json"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.flaky(retries=6, delay=1)
|
||||
async def test_langfuse_logging_with_various_metadata_types(self, mock_setup, test_metadata, response_json_file):
|
||||
"""Test Langfuse logging with various metadata types including non-serializable objects"""
|
||||
setup = mock_setup
|
||||
|
||||
if test_metadata is not None:
|
||||
test_metadata["trace_id"] = setup["trace_id"]
|
||||
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata=test_metadata,
|
||||
)
|
||||
|
||||
await self._verify_langfuse_call(
|
||||
setup["mock_post"],
|
||||
response_json_file,
|
||||
setup["trace_id"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_malformed_llm_response(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with malformed LLM response"""
|
||||
setup = mock_setup
|
||||
litellm.turn_on_debug()
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
mock_response = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=10,
|
||||
total_tokens=20,
|
||||
),
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=mock_response,
|
||||
metadata={"trace_id": setup["trace_id"]},
|
||||
)
|
||||
await self._verify_langfuse_call(setup["mock_post"], "completion_with_no_choices.json", setup["trace_id"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_bedrock_llm_response(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with malformed LLM response"""
|
||||
setup = mock_setup
|
||||
litellm.turn_on_debug()
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
mock_response = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=10,
|
||||
total_tokens=20,
|
||||
),
|
||||
model="anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await litellm.acompletion(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=mock_response,
|
||||
metadata={"trace_id": setup["trace_id"]},
|
||||
aws_access_key_id="fake-key",
|
||||
aws_secret_access_key="fake-key",
|
||||
aws_region="us-east-1",
|
||||
)
|
||||
await self._verify_langfuse_call(
|
||||
setup["mock_post"],
|
||||
"completion_with_bedrock_call.json",
|
||||
setup["trace_id"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_completion_with_vertex_llm_response(self, mock_setup):
|
||||
"""Test Langfuse logging for chat completion with malformed LLM response"""
|
||||
setup = mock_setup
|
||||
litellm.turn_on_debug()
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
mock_response = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=10,
|
||||
total_tokens=20,
|
||||
),
|
||||
model="vertex/gemini-3-flash-preview",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await litellm.acompletion(
|
||||
model="vertex_ai/gemini-3-flash-preview",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=mock_response,
|
||||
metadata={"trace_id": setup["trace_id"]},
|
||||
vertex_credentials="my-mock-credentials",
|
||||
api_key="my-mock-credentials-2",
|
||||
)
|
||||
await self._verify_langfuse_call(
|
||||
setup["mock_post"],
|
||||
"completion_with_vertex_call.json",
|
||||
setup["trace_id"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_vllm_embedding(self, mock_setup):
|
||||
"""
|
||||
Test that the request sent to the vllm embedding endpoint is correct.
|
||||
|
||||
Verifies the request body matches the expected JSON fixture,
|
||||
including that the hosted_vllm/ prefix is stripped from the model name
|
||||
and that no unexpected fields (e.g. encoding_format) are included.
|
||||
"""
|
||||
setup = mock_setup
|
||||
|
||||
vllm_response_data = {
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
|
||||
"model": "BAAI/bge-small-en-v1.5",
|
||||
"usage": {"prompt_tokens": 10, "total_tokens": 10},
|
||||
}
|
||||
mock_vllm_response = httpx.Response(
|
||||
status_code=200,
|
||||
json=vllm_response_data,
|
||||
)
|
||||
|
||||
mock_async_client = AsyncHTTPHandler()
|
||||
mock_async_client.post = AsyncMock(return_value=mock_vllm_response)
|
||||
|
||||
with patch(LANGFUSE_EXPORT_POST, setup["mock_post"]):
|
||||
await litellm.aembedding(
|
||||
model="hosted_vllm/BAAI/bge-small-en-v1.5",
|
||||
input=["Hello from litellm!"],
|
||||
api_base="http://my-fake-vllm.com/v1",
|
||||
metadata={"trace_id": setup["trace_id"]},
|
||||
client=mock_async_client,
|
||||
)
|
||||
|
||||
# Verify the request sent to vllm matches the expected JSON fixture
|
||||
assert mock_async_client.post.call_count == 1
|
||||
actual_vllm_request = mock_async_client.post.call_args.kwargs["json"]
|
||||
|
||||
pwd = os.path.dirname(os.path.realpath(__file__))
|
||||
expected_body_path = os.path.join(pwd, "langfuse_expected_request_body", "embedding_with_vllm.json")
|
||||
with open(expected_body_path, "r") as f:
|
||||
expected_vllm_request = json.load(f)
|
||||
|
||||
assert actual_vllm_request == expected_vllm_request, (
|
||||
f"vllm request body mismatch:\n"
|
||||
f"actual: {json.dumps(actual_vllm_request, indent=2)}\n"
|
||||
f"expected: {json.dumps(expected_vllm_request, indent=2)}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=1)
|
||||
async def test_langfuse_logging_with_router(self, mock_setup):
|
||||
"""Test Langfuse logging with router"""
|
||||
litellm.turn_on_debug()
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"mock_response": "Hello! How can I assist you today?",
|
||||
"api_key": "test_api_key",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch(LANGFUSE_EXPORT_POST, mock_setup["mock_post"]):
|
||||
mock_response = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=10,
|
||||
total_tokens=20,
|
||||
),
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await router.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=mock_response,
|
||||
metadata={"trace_id": mock_setup["trace_id"]},
|
||||
)
|
||||
await self._verify_langfuse_call(
|
||||
mock_setup["mock_post"],
|
||||
"completion_with_router.json",
|
||||
mock_setup["trace_id"],
|
||||
)
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
from litellm.litellm_core_utils.litellm_logging import DynamicLoggingCache
|
||||
|
||||
|
||||
def test_langfuse_e2e_sync(monkeypatch):
|
||||
"""A sync completion must reach langfuse over the wire, not just build a span.
|
||||
|
||||
v4 exports OTLP over ``requests`` rather than the v2 ingestion endpoint over
|
||||
httpx, so this stands up a real receiver and asserts langfuse posted to it.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import litellm
|
||||
from litellm import completion
|
||||
from litellm.integrations.langfuse.langfuse import LangFuseLogger
|
||||
from litellm.integrations.langfuse.langfuse_prompt_management import langfuse_client_init
|
||||
from litellm.litellm_core_utils import litellm_logging
|
||||
|
||||
received_paths = []
|
||||
|
||||
class _Receiver(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
received_paths.append(self.path)
|
||||
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), _Receiver)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
monkeypatch.setenv("LANGFUSE_HOST", f"http://127.0.0.1:{server.server_port}")
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-e2e-sync")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-e2e-sync")
|
||||
monkeypatch.setattr(litellm, "success_callback", ["langfuse"])
|
||||
monkeypatch.setattr(litellm_logging, "langFuseLogger", None)
|
||||
monkeypatch.setattr(litellm_logging, "in_memory_dynamic_logger_cache", DynamicLoggingCache())
|
||||
monkeypatch.setattr(litellm_logging, "_in_memory_loggers", [])
|
||||
langfuse_client_init.cache_clear()
|
||||
|
||||
try:
|
||||
completion(
|
||||
model="openai/my-fake-endpoint",
|
||||
messages=[{"role": "user", "content": "hello from litellm"}],
|
||||
stream=False,
|
||||
mock_response="Hello from litellm 2",
|
||||
)
|
||||
for logger in litellm.logging_callback_manager.get_all_callbacks():
|
||||
if isinstance(logger, LangFuseLogger):
|
||||
logger.flush()
|
||||
deadline = time.time() + 10
|
||||
while not received_paths and time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
server.shutdown()
|
||||
|
||||
assert received_paths, "langfuse exported nothing"
|
||||
assert all(path.endswith("/api/public/otel/v1/traces") for path in received_paths)
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import io
|
||||
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm import completion
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy.utils import ServiceTypes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dd_log_db_spend_failure_metrics():
|
||||
from litellm._service_logger import ServiceLogging
|
||||
from litellm.integrations.datadog.datadog import DataDogLogger
|
||||
|
||||
dd_logger = DataDogLogger()
|
||||
with patch.object(dd_logger, "async_service_failure_hook", new_callable=AsyncMock):
|
||||
service_logging_obj = ServiceLogging()
|
||||
|
||||
litellm.service_callback = [dd_logger]
|
||||
|
||||
await service_logging_obj.async_service_failure_hook(
|
||||
service=ServiceTypes.DB,
|
||||
call_type="test_call_type",
|
||||
error="test_error",
|
||||
duration=1.0,
|
||||
)
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import asyncio
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
import litellm
|
||||
|
||||
from litellm_enterprise.enterprise_callbacks.pagerduty.pagerduty import (
|
||||
PagerDutyAlerting,
|
||||
AlertingConfig,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagerduty_alerting():
|
||||
pagerduty = PagerDutyAlerting(
|
||||
alerting_args=AlertingConfig(
|
||||
failure_threshold=1, failure_threshold_window_seconds=10
|
||||
)
|
||||
)
|
||||
litellm.callbacks = [pagerduty]
|
||||
|
||||
try:
|
||||
await litellm.acompletion(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
mock_response="litellm.RateLimitError",
|
||||
)
|
||||
except litellm.RateLimitError:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagerduty_alerting_high_failure_rate():
|
||||
pagerduty = PagerDutyAlerting(
|
||||
alerting_args=AlertingConfig(
|
||||
failure_threshold=3, failure_threshold_window_seconds=600
|
||||
)
|
||||
)
|
||||
litellm.callbacks = [pagerduty]
|
||||
|
||||
try:
|
||||
await litellm.acompletion(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
mock_response="litellm.RateLimitError",
|
||||
)
|
||||
except litellm.RateLimitError:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# make 3 more fails
|
||||
for _ in range(3):
|
||||
try:
|
||||
await litellm.acompletion(
|
||||
model="gpt-5-mini",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
mock_response="litellm.RateLimitError",
|
||||
)
|
||||
except litellm.RateLimitError:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pagerduty_hanging_request_alerting():
|
||||
pagerduty = PagerDutyAlerting(
|
||||
alerting_args=AlertingConfig(hanging_threshold_seconds=0.0000001)
|
||||
)
|
||||
litellm.callbacks = [pagerduty]
|
||||
|
||||
await pagerduty.async_pre_call_hook(
|
||||
cache=None,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test",
|
||||
key_alias="test-pagerduty",
|
||||
team_alias="test-team",
|
||||
org_id="test-org",
|
||||
user_id="test-user",
|
||||
end_user_id="test-end-user",
|
||||
),
|
||||
data={"model": "gpt-5.5", "messages": [{"role": "user", "content": "hi"}]},
|
||||
call_type="completion",
|
||||
)
|
||||
|
||||
await litellm.acompletion(
|
||||
model="gpt-5.5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import unquote
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.sqs import SQSLogger
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
from litellm.litellm_core_utils.app_crypto import AppCrypto
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_sqs_logger_flush():
|
||||
expected_queue_url = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
|
||||
expected_region = "us-east-1"
|
||||
|
||||
sqs_logger = SQSLogger(
|
||||
sqs_queue_url=expected_queue_url,
|
||||
sqs_region_name=expected_region,
|
||||
sqs_flush_interval=1,
|
||||
)
|
||||
|
||||
# Mock the httpx client
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
sqs_logger.async_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
litellm.callbacks = [sqs_logger]
|
||||
|
||||
await litellm.acompletion(
|
||||
model="gpt-5.5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Verify that httpx post was called
|
||||
sqs_logger.async_httpx_client.post.assert_called()
|
||||
|
||||
# Get the call arguments
|
||||
call_args = sqs_logger.async_httpx_client.post.call_args
|
||||
|
||||
# Verify the URL is correct
|
||||
called_url = call_args[0][0] # First positional argument
|
||||
assert (
|
||||
called_url == expected_queue_url
|
||||
), f"Expected URL {expected_queue_url}, got {called_url}"
|
||||
|
||||
# Verify the payload contains StandardLoggingPayload data
|
||||
called_data = call_args.kwargs["data"]
|
||||
|
||||
# Extract the MessageBody from the URL-encoded data
|
||||
# Format: "Action=SendMessage&Version=2012-11-05&MessageBody=<url_encoded_json>"
|
||||
assert "Action=SendMessage" in called_data
|
||||
assert "Version=2012-11-05" in called_data
|
||||
assert "MessageBody=" in called_data
|
||||
|
||||
# Extract and decode the message body
|
||||
message_body_start = called_data.find("MessageBody=") + len("MessageBody=")
|
||||
message_body_encoded = called_data[message_body_start:]
|
||||
message_body_json = unquote(message_body_encoded)
|
||||
|
||||
# Parse the JSON to verify it's a StandardLoggingPayload
|
||||
payload_data = json.loads(message_body_json)
|
||||
|
||||
# Verify it has the expected StandardLoggingPayload structure
|
||||
assert "model" in payload_data
|
||||
assert "messages" in payload_data
|
||||
assert "response" in payload_data
|
||||
assert payload_data["model"] == "gpt-5.5"
|
||||
assert len(payload_data["messages"]) == 1
|
||||
assert payload_data["messages"][0]["role"] == "user"
|
||||
assert payload_data["messages"][0]["content"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_sqs_logger_error_flush():
|
||||
expected_queue_url = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
|
||||
expected_region = "us-east-1"
|
||||
|
||||
sqs_logger = SQSLogger(
|
||||
sqs_queue_url=expected_queue_url,
|
||||
sqs_region_name=expected_region,
|
||||
sqs_flush_interval=1,
|
||||
)
|
||||
|
||||
# Mock the httpx client
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = Exception("Something went wrong")
|
||||
sqs_logger.async_httpx_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
litellm.callbacks = [sqs_logger]
|
||||
|
||||
await litellm.acompletion(
|
||||
model="gpt-5.5",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
mock_response="Error occurred",
|
||||
)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Verify that httpx post was called
|
||||
sqs_logger.async_httpx_client.post.assert_called()
|
||||
|
||||
# Get the call arguments
|
||||
call_args = sqs_logger.async_httpx_client.post.call_args
|
||||
|
||||
# Verify the URL is correct
|
||||
called_url = call_args[0][0] # First positional argument
|
||||
assert (
|
||||
called_url == expected_queue_url
|
||||
), f"Expected URL {expected_queue_url}, got {called_url}"
|
||||
|
||||
# Verify the payload contains StandardLoggingPayload data
|
||||
called_data = call_args.kwargs["data"]
|
||||
|
||||
# Extract the MessageBody from the URL-encoded data
|
||||
# Format: "Action=SendMessage&Version=2012-11-05&MessageBody=<url_encoded_json>"
|
||||
assert "Action=SendMessage" in called_data
|
||||
assert "Version=2012-11-05" in called_data
|
||||
assert "MessageBody=" in called_data
|
||||
|
||||
# Extract and decode the message body
|
||||
message_body_start = called_data.find("MessageBody=") + len("MessageBody=")
|
||||
message_body_encoded = called_data[message_body_start:]
|
||||
message_body_json = unquote(message_body_encoded)
|
||||
|
||||
# Parse the JSON to verify it's a StandardLoggingPayload
|
||||
payload_data = json.loads(message_body_json)
|
||||
|
||||
# Verify it has the expected StandardLoggingPayload structure
|
||||
assert "model" in payload_data
|
||||
assert "messages" in payload_data
|
||||
assert "response" in payload_data
|
||||
assert payload_data["model"] == "gpt-5.5"
|
||||
assert len(payload_data["messages"]) == 1
|
||||
assert payload_data["messages"][0]["role"] == "user"
|
||||
assert payload_data["messages"][0]["content"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_batch_triggers_tasks(monkeypatch):
|
||||
monkeypatch.setattr("litellm.aws_sqs_callback_params", {})
|
||||
monkeypatch.setattr(asyncio, "create_task", MagicMock())
|
||||
logger = SQSLogger(sqs_queue_url="https://example.com", sqs_region_name="us-west-2")
|
||||
|
||||
logger.async_send_message = AsyncMock()
|
||||
logger.log_queue = [{"log": 1}, {"log": 2}]
|
||||
|
||||
await logger.async_send_batch()
|
||||
# It uses asyncio.create_task() so direct await count = 0 is expected
|
||||
asyncio.create_task.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strip_base64_keeps_non_file_content():
|
||||
logger = SQSLogger(sqs_strip_base64_files=True)
|
||||
|
||||
payload = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Just text"},
|
||||
{"type": "text", "text": "Another message"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
stripped = await logger._strip_base64_from_messages(payload)
|
||||
|
||||
# Should not modify normal text messages
|
||||
assert stripped["messages"][0]["content"] == payload["messages"][0]["content"]
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
"""
|
||||
Base test class for Anthropic Messages API prompt caching E2E tests.
|
||||
|
||||
Tests that prompt caching works correctly via litellm.anthropic.messages interface
|
||||
by making actual API calls and validating usage metrics.
|
||||
|
||||
Per AWS docs (https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html):
|
||||
- Converse API uses: cachePoint: { type: "default" }
|
||||
- InvokeModel API uses: cache_control: { type: "ephemeral" }
|
||||
- Claude 3.7 Sonnet: GA, 1024 min tokens
|
||||
- Claude 3.5 Haiku: GA, 2048 min tokens
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
|
||||
import pytest
|
||||
import litellm
|
||||
|
||||
from tests._live_test_helpers import _skip_live_prompt_caching_test
|
||||
|
||||
# Large document for caching tests (needs 1024+ tokens for Claude models)
|
||||
LARGE_DOCUMENT_FOR_CACHING = """
|
||||
This is a comprehensive legal agreement between Party A and Party B.
|
||||
|
||||
ARTICLE 1: DEFINITIONS
|
||||
1.1 "Agreement" means this document and all attachments.
|
||||
1.2 "Confidential Information" means any non-public information.
|
||||
1.3 "Effective Date" means the date of last signature.
|
||||
1.4 "Term" means the period during which this Agreement is in effect.
|
||||
|
||||
ARTICLE 2: SCOPE OF SERVICES
|
||||
2.1 Party A agrees to provide the following services...
|
||||
2.2 Party B agrees to compensate Party A for services rendered...
|
||||
2.3 All services shall be performed in a professional manner...
|
||||
|
||||
ARTICLE 3: PAYMENT TERMS
|
||||
3.1 Payment shall be made within 30 days of invoice receipt.
|
||||
3.2 Late payments shall accrue interest at 1.5% per month.
|
||||
3.3 All fees are non-refundable unless otherwise specified.
|
||||
|
||||
ARTICLE 4: INTELLECTUAL PROPERTY
|
||||
4.1 All pre-existing IP remains with the original owner.
|
||||
4.2 Work product created under this Agreement shall be owned by Party B.
|
||||
4.3 Party A grants a license to use any tools or methodologies.
|
||||
|
||||
ARTICLE 5: CONFIDENTIALITY
|
||||
5.1 Both parties agree to maintain confidentiality of all shared information.
|
||||
5.2 Confidential information shall not be disclosed to third parties.
|
||||
5.3 This obligation survives termination of the Agreement.
|
||||
|
||||
ARTICLE 6: TERMINATION
|
||||
6.1 Either party may terminate with 30 days written notice.
|
||||
6.2 Immediate termination is permitted for material breach.
|
||||
6.3 Upon termination, all confidential information must be returned.
|
||||
|
||||
ARTICLE 7: LIMITATION OF LIABILITY
|
||||
7.1 Neither party shall be liable for consequential damages.
|
||||
7.2 Total liability shall not exceed fees paid in the prior 12 months.
|
||||
7.3 This limitation does not apply to willful misconduct.
|
||||
|
||||
ARTICLE 8: DISPUTE RESOLUTION
|
||||
8.1 Disputes shall first be addressed through good faith negotiation.
|
||||
8.2 If negotiation fails, disputes shall be submitted to arbitration.
|
||||
8.3 Arbitration shall be conducted under AAA rules.
|
||||
|
||||
ARTICLE 9: GENERAL PROVISIONS
|
||||
9.1 This Agreement constitutes the entire understanding between parties.
|
||||
9.2 Amendments must be in writing and signed by both parties.
|
||||
9.3 This Agreement shall be governed by the laws of Delaware.
|
||||
9.4 Neither party may assign this Agreement without consent.
|
||||
9.5 Waiver of any provision shall not constitute ongoing waiver.
|
||||
|
||||
IN WITNESS WHEREOF, the parties have executed this Agreement.
|
||||
""" * 8 # Repeat to ensure we have enough tokens (need 1024+ for Claude models)
|
||||
|
||||
|
||||
class BaseAnthropicMessagesPromptCachingTest(ABC):
|
||||
"""
|
||||
Base test class for prompt caching E2E tests across different providers.
|
||||
|
||||
Subclasses must implement:
|
||||
- get_model(): Returns the model string to use for tests
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_model(self) -> str:
|
||||
"""
|
||||
Returns the model string to use for tests.
|
||||
|
||||
Examples:
|
||||
- "bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
- "bedrock/invoke/anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_messages_with_cache_control(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns test messages with cache_control set on content blocks.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": LARGE_DOCUMENT_FOR_CACHING,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "What are the payment terms in this agreement?",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_caching_returns_cache_creation_tokens(self):
|
||||
"""
|
||||
E2E test: First call should return cache_creation_input_tokens > 0.
|
||||
|
||||
This validates that the cache_control field is being passed through
|
||||
correctly and the provider is creating a cache.
|
||||
"""
|
||||
_skip_live_prompt_caching_test()
|
||||
litellm.turn_on_debug()
|
||||
|
||||
messages = self.get_messages_with_cache_control()
|
||||
|
||||
response = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
print(f"Response: {json.dumps(response, indent=2, default=str)}")
|
||||
|
||||
# Validate response structure
|
||||
assert "usage" in response, "Response should contain usage"
|
||||
usage = response["usage"]
|
||||
|
||||
# Check for cache tokens in usage
|
||||
cache_creation = usage.get("cache_creation_input_tokens", 0)
|
||||
cache_read = usage.get("cache_read_input_tokens", 0)
|
||||
|
||||
print(f"cache_creation_input_tokens: {cache_creation}")
|
||||
print(f"cache_read_input_tokens: {cache_read}")
|
||||
|
||||
# First call should create cache (cache_creation > 0) OR read from existing cache
|
||||
assert cache_creation > 0 or cache_read > 0, (
|
||||
f"Expected cache_creation_input_tokens > 0 or cache_read_input_tokens > 0, "
|
||||
f"but got cache_creation={cache_creation}, cache_read={cache_read}. "
|
||||
f"This indicates cache_control is not being passed through correctly."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_caching_returns_cache_read_tokens_on_second_call(self):
|
||||
"""
|
||||
E2E test: Second call with same content should return cache_read_input_tokens > 0.
|
||||
|
||||
This validates that caching is working end-to-end.
|
||||
"""
|
||||
_skip_live_prompt_caching_test()
|
||||
litellm.turn_on_debug()
|
||||
|
||||
messages = self.get_messages_with_cache_control()
|
||||
|
||||
# First call - creates cache
|
||||
response1 = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
print(
|
||||
f"First response usage: {json.dumps(response1.get('usage', {}), indent=2)}"
|
||||
)
|
||||
|
||||
# Second call - should read from cache
|
||||
response2 = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
print(
|
||||
f"Second response usage: {json.dumps(response2.get('usage', {}), indent=2)}"
|
||||
)
|
||||
|
||||
usage = response2.get("usage", {})
|
||||
cache_read = usage.get("cache_read_input_tokens", 0)
|
||||
|
||||
# Second call should read from cache
|
||||
assert cache_read > 0, (
|
||||
f"Expected cache_read_input_tokens > 0 on second call, "
|
||||
f"but got {cache_read}. Full usage: {usage}"
|
||||
)
|
||||
|
||||
def _parse_sse_chunks(self, chunk: bytes) -> list:
|
||||
"""
|
||||
Parse SSE format chunks and return list of JSON objects.
|
||||
"""
|
||||
results = []
|
||||
chunk_str = chunk.decode("utf-8")
|
||||
for line in chunk_str.split("\n"):
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
json_data = json.loads(line[6:]) # Skip the 'data: ' prefix
|
||||
results.append(json_data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return results
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_caching_streaming_returns_cache_tokens(self):
|
||||
"""
|
||||
E2E test: Streaming response should include cache tokens in usage.
|
||||
|
||||
This validates that cache_creation_input_tokens and cache_read_input_tokens
|
||||
are correctly returned in the streaming response's message_delta event.
|
||||
"""
|
||||
_skip_live_prompt_caching_test()
|
||||
litellm.turn_on_debug()
|
||||
|
||||
messages = self.get_messages_with_cache_control()
|
||||
|
||||
response = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Collect all chunks and find the message_delta with usage
|
||||
cache_creation = 0
|
||||
cache_read = 0
|
||||
found_usage = False
|
||||
|
||||
async for chunk in response:
|
||||
# Handle SSE format chunks (bytes)
|
||||
if isinstance(chunk, bytes):
|
||||
json_chunks = self._parse_sse_chunks(chunk)
|
||||
for json_data in json_chunks:
|
||||
print(
|
||||
f"Parsed chunk: {json.dumps(json_data, indent=2, default=str)}"
|
||||
)
|
||||
|
||||
# Look for message_delta with usage (final chunk)
|
||||
if json_data.get("type") == "message_delta":
|
||||
usage = json_data.get("usage", {})
|
||||
if usage:
|
||||
found_usage = True
|
||||
cache_creation = max(
|
||||
cache_creation,
|
||||
usage.get("cache_creation_input_tokens", 0),
|
||||
)
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
print(
|
||||
f"Found usage in message_delta: cache_creation={cache_creation}, cache_read={cache_read}"
|
||||
)
|
||||
|
||||
# Also check message_start for usage (Anthropic includes it there too)
|
||||
if json_data.get("type") == "message_start":
|
||||
message = json_data.get("message", {})
|
||||
usage = message.get("usage", {})
|
||||
if usage:
|
||||
found_usage = True
|
||||
cache_creation = max(
|
||||
cache_creation,
|
||||
usage.get("cache_creation_input_tokens", 0),
|
||||
)
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
print(
|
||||
f"Found usage in message_start: cache_creation={cache_creation}, cache_read={cache_read}"
|
||||
)
|
||||
elif isinstance(chunk, dict):
|
||||
print(f"Dict chunk: {json.dumps(chunk, indent=2, default=str)}")
|
||||
# Handle dict chunks directly
|
||||
if chunk.get("type") == "message_delta":
|
||||
usage = chunk.get("usage", {})
|
||||
if usage:
|
||||
found_usage = True
|
||||
cache_creation = max(
|
||||
cache_creation, usage.get("cache_creation_input_tokens", 0)
|
||||
)
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
|
||||
if chunk.get("type") == "message_start":
|
||||
message = chunk.get("message", {})
|
||||
usage = message.get("usage", {})
|
||||
if usage:
|
||||
found_usage = True
|
||||
cache_creation = max(
|
||||
cache_creation, usage.get("cache_creation_input_tokens", 0)
|
||||
)
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
|
||||
assert found_usage, "Expected to find usage in streaming response"
|
||||
|
||||
# Should have cache tokens (either creation or read)
|
||||
assert cache_creation > 0 or cache_read > 0, (
|
||||
f"Expected cache_creation_input_tokens > 0 or cache_read_input_tokens > 0 in streaming response, "
|
||||
f"but got cache_creation={cache_creation}, cache_read={cache_read}. "
|
||||
f"This indicates cache tokens are not being passed through in streaming mode."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_caching_streaming_second_call_returns_cache_read(self):
|
||||
"""
|
||||
E2E test: Second streaming call should return cache_read_input_tokens > 0.
|
||||
"""
|
||||
_skip_live_prompt_caching_test()
|
||||
litellm.turn_on_debug()
|
||||
|
||||
messages = self.get_messages_with_cache_control()
|
||||
|
||||
# First call - creates cache
|
||||
response1 = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Consume the first stream
|
||||
async for chunk in response1:
|
||||
pass
|
||||
|
||||
# Second call - should read from cache
|
||||
response2 = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
max_tokens=100,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
cache_read = 0
|
||||
async for chunk in response2:
|
||||
# Handle SSE format chunks (bytes)
|
||||
if isinstance(chunk, bytes):
|
||||
json_chunks = self._parse_sse_chunks(chunk)
|
||||
for json_data in json_chunks:
|
||||
print(
|
||||
f"Second call parsed chunk: {json.dumps(json_data, indent=2, default=str)}"
|
||||
)
|
||||
|
||||
if json_data.get("type") == "message_delta":
|
||||
usage = json_data.get("usage", {})
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
|
||||
if json_data.get("type") == "message_start":
|
||||
message = json_data.get("message", {})
|
||||
usage = message.get("usage", {})
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
elif isinstance(chunk, dict):
|
||||
if chunk.get("type") == "message_delta":
|
||||
usage = chunk.get("usage", {})
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
|
||||
if chunk.get("type") == "message_start":
|
||||
message = chunk.get("message", {})
|
||||
usage = message.get("usage", {})
|
||||
cache_read = max(
|
||||
cache_read, usage.get("cache_read_input_tokens", 0)
|
||||
)
|
||||
|
||||
assert cache_read > 0, (
|
||||
f"Expected cache_read_input_tokens > 0 on second streaming call, "
|
||||
f"but got {cache_read}"
|
||||
)
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
"""
|
||||
Base test class for Anthropic Messages API tool search E2E tests.
|
||||
|
||||
Tests that tool search works correctly via litellm.anthropic.messages interface
|
||||
by making actual API calls and validating that tool search discovers deferred tools.
|
||||
|
||||
Reference: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
|
||||
"""
|
||||
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
import pytest
|
||||
import litellm
|
||||
|
||||
|
||||
# Sample tools for tool search testing
|
||||
def get_deferred_tools() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns a list of tools with defer_loading: true.
|
||||
These tools should only be discovered via tool search.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a location",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g. San Francisco, CA",
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
},
|
||||
"defer_loading": True,
|
||||
},
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get the current stock price for a ticker symbol",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "The stock ticker symbol, e.g. AAPL",
|
||||
}
|
||||
},
|
||||
"required": ["ticker"],
|
||||
},
|
||||
"defer_loading": True,
|
||||
},
|
||||
{
|
||||
"name": "search_web",
|
||||
"description": "Search the web for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "The search query"}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"defer_loading": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_tool_search_tool_regex() -> Dict[str, Any]:
|
||||
"""Returns the tool search tool using regex variant."""
|
||||
return {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}
|
||||
|
||||
|
||||
def get_tool_search_tool_bm25() -> Dict[str, Any]:
|
||||
"""Returns the tool search tool using BM25 variant."""
|
||||
return {"type": "tool_search_tool_bm25_20251119", "name": "tool_search_tool_bm25"}
|
||||
|
||||
|
||||
class BaseAnthropicMessagesToolSearchTest(ABC):
|
||||
"""
|
||||
Base test class for tool search E2E tests across different providers.
|
||||
|
||||
Subclasses must implement:
|
||||
- get_model(): Returns the model string to use for tests
|
||||
|
||||
Tests pass the anthropic-beta header via extra_headers to validate
|
||||
that the header is correctly forwarded to downstream providers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_model(self) -> str:
|
||||
"""
|
||||
Returns the model string to use for tests.
|
||||
|
||||
Examples:
|
||||
- "anthropic/claude-sonnet-4-5-20250929"
|
||||
- "vertex_ai/claude-sonnet-4-5@20250929"
|
||||
- "bedrock/invoke/anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_extra_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
Returns extra headers to pass with the request.
|
||||
Includes the anthropic-beta header for tool search.
|
||||
|
||||
This is what claude code forwards, simulate the same behavior here.
|
||||
"""
|
||||
return {"anthropic-beta": "advanced-tool-use-2025-11-20"}
|
||||
|
||||
def get_tools_with_tool_search(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns tools list with tool search tool and deferred tools.
|
||||
"""
|
||||
return [get_tool_search_tool_regex()] + get_deferred_tools()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_search_basic_request(self):
|
||||
"""
|
||||
E2E test: Basic tool search request should succeed.
|
||||
|
||||
This validates that the tool search beta header is being passed via
|
||||
extra_headers and forwarded correctly to the downstream provider.
|
||||
"""
|
||||
litellm.turn_on_debug()
|
||||
|
||||
tools = self.get_tools_with_tool_search()
|
||||
messages = [{"role": "user", "content": "What's the weather in San Francisco?"}]
|
||||
|
||||
response = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=1024,
|
||||
extra_headers=self.get_extra_headers(),
|
||||
)
|
||||
|
||||
print(f"Response: {json.dumps(response, indent=2, default=str)}")
|
||||
|
||||
# Validate response structure
|
||||
assert "content" in response, "Response should contain content"
|
||||
assert "usage" in response, "Response should contain usage"
|
||||
|
||||
# The model should either respond with text or use a tool
|
||||
content = response.get("content", [])
|
||||
assert len(content) > 0, "Response should have content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.flaky(retries=3, delay=5)
|
||||
async def test_tool_search_streaming(self):
|
||||
"""
|
||||
E2E test: Tool search should work with streaming responses.
|
||||
"""
|
||||
litellm.turn_on_debug()
|
||||
|
||||
tools = self.get_tools_with_tool_search()
|
||||
messages = [{"role": "user", "content": "What's the weather like in Tokyo?"}]
|
||||
|
||||
response = await litellm.anthropic.messages.acreate(
|
||||
model=self.get_model(),
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_tokens=1024,
|
||||
stream=True,
|
||||
extra_headers=self.get_extra_headers(),
|
||||
)
|
||||
|
||||
# Collect all chunks
|
||||
chunks = []
|
||||
async for chunk in response:
|
||||
if isinstance(chunk, bytes):
|
||||
chunk_str = chunk.decode("utf-8")
|
||||
for line in chunk_str.split("\n"):
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
json_data = json.loads(line[6:])
|
||||
chunks.append(json_data)
|
||||
print(
|
||||
f"Chunk: {json.dumps(json_data, indent=2, default=str)}"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif isinstance(chunk, dict):
|
||||
chunks.append(chunk)
|
||||
print(f"Chunk: {json.dumps(chunk, indent=2, default=str)}")
|
||||
|
||||
# Should have received chunks
|
||||
assert len(chunks) > 0, "Expected to receive streaming chunks"
|
||||
|
||||
# Should have message_start
|
||||
message_starts = [c for c in chunks if c.get("type") == "message_start"]
|
||||
assert len(message_starts) > 0, "Expected message_start in streaming response"
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator, Dict, Any
|
||||
import asyncio
|
||||
import unittest.mock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
from litellm.llms.anthropic.pass_through.messages.handler import (
|
||||
anthropic_messages,
|
||||
)
|
||||
|
||||
from typing import Optional
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.router import Router
|
||||
import importlib
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
|
||||
|
||||
class TestCustomLogger(CustomLogger):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.logged_standard_logging_payload: Optional[StandardLoggingPayload] = None
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
print("inside async_log_success_event")
|
||||
self.logged_standard_logging_payload = kwargs.get("standard_logging_object")
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseAnthropicMessagesTest:
|
||||
"""Base class for anthropic messages tests to reduce code duplication"""
|
||||
|
||||
@property
|
||||
def model_config(self) -> Dict[str, Any]:
|
||||
"""Override in subclasses to provide model-specific configuration"""
|
||||
raise NotImplementedError("Subclasses must implement model_config")
|
||||
|
||||
@property
|
||||
def expected_model_name_in_logging(self) -> str:
|
||||
"""
|
||||
This is the model name that is expected to be in the logging payload
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement expected_model_name_in_logging"
|
||||
)
|
||||
|
||||
def _validate_response(self, response: Any):
|
||||
"""Validate non-streaming response structure"""
|
||||
# Handle type checking - response should be a dict for non-streaming
|
||||
if isinstance(response, AsyncIterator):
|
||||
pytest.fail("Expected non-streaming response but got AsyncIterator")
|
||||
|
||||
assert isinstance(
|
||||
response, dict
|
||||
), f"Expected dict response, got {type(response)}"
|
||||
assert "id" in response
|
||||
assert "content" in response
|
||||
assert "model" in response
|
||||
assert response.get("role") == "assistant"
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
|
||||
from tests._vcr_conftest_common import ( # noqa: E402,F401
|
||||
VerboseReporterState,
|
||||
_pin_multipart_boundary,
|
||||
apply_vcr_auto_marker_to_items,
|
||||
emit_cassette_cache_session_banner,
|
||||
emit_vcr_classification_summary,
|
||||
emit_vcr_diagnostic_log,
|
||||
install_live_call_probe,
|
||||
record_vcr_outcome,
|
||||
register_persister_if_enabled,
|
||||
reset_vcr_diag_dir,
|
||||
vcr_config_dict,
|
||||
)
|
||||
|
||||
_VCR_INCOMPATIBLE_NODEID_SUFFIXES: tuple[str, ...] = ()
|
||||
|
||||
|
||||
_verbose_state = VerboseReporterState()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vcr_config():
|
||||
return vcr_config_dict()
|
||||
|
||||
|
||||
def pytest_recording_configure(config, vcr):
|
||||
register_persister_if_enabled(vcr)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
outcome = yield
|
||||
rep = outcome.get_result()
|
||||
setattr(item, f"rep_{rep.when}", rep)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _vcr_outcome_gate(request, vcr):
|
||||
install_live_call_probe(request, vcr)
|
||||
yield
|
||||
record_vcr_outcome(request, vcr)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _drain_logging_worker():
|
||||
"""
|
||||
The logging queue is bound to the running loop, so anything left queued when a test's loop
|
||||
goes away is carried onto the next loop and fires against that test's callbacks.
|
||||
"""
|
||||
GLOBAL_LOGGING_WORKER.start()
|
||||
try:
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
await GLOBAL_LOGGING_WORKER.stop()
|
||||
yield
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
_verbose_state.remember_pluginmanager(config)
|
||||
reset_vcr_diag_dir()
|
||||
|
||||
|
||||
def pytest_runtest_logreport(report):
|
||||
_verbose_state.maybe_emit_verdict(report)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
apply_vcr_auto_marker_to_items(
|
||||
items, skip_nodeid_suffixes=_VCR_INCOMPATIBLE_NODEID_SUFFIXES
|
||||
)
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
||||
emit_cassette_cache_session_banner(terminalreporter)
|
||||
emit_vcr_classification_summary(terminalreporter)
|
||||
emit_vcr_diagnostic_log(terminalreporter)
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
import unittest.mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from litellm.llms.anthropic.pass_through.messages.handler import (
|
||||
anthropic_messages,
|
||||
)
|
||||
|
||||
from typing import Optional
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from base_anthropic_unified_messages_test import BaseAnthropicMessagesTest
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _validate_anthropic_response(response: Dict[str, Any]):
|
||||
assert "id" in response
|
||||
assert "content" in response
|
||||
assert "model" in response
|
||||
assert response["role"] == "assistant"
|
||||
|
||||
|
||||
class TestAnthropicDirectAPI(BaseAnthropicMessagesTest):
|
||||
"""Tests for direct Anthropic API calls"""
|
||||
|
||||
test_non_streaming_base = None
|
||||
|
||||
@property
|
||||
def model_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"api_key": os.getenv("ANTHROPIC_API_KEY"),
|
||||
}
|
||||
|
||||
@property
|
||||
def expected_model_name_in_logging(self) -> str:
|
||||
"""
|
||||
This is the model name that is expected to be in the logging payload
|
||||
"""
|
||||
return "claude-haiku-4-5-20251001"
|
||||
|
||||
|
||||
class TestAnthropicBedrockAPI(BaseAnthropicMessagesTest):
|
||||
"""Tests for Anthropic via Bedrock"""
|
||||
|
||||
@property
|
||||
def model_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"model": "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
}
|
||||
|
||||
@property
|
||||
def expected_model_name_in_logging(self) -> str:
|
||||
"""
|
||||
This is the model name that is expected to be in the logging payload
|
||||
"""
|
||||
return "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
|
||||
|
||||
class TestAnthropicOpenAIAPI(BaseAnthropicMessagesTest):
|
||||
"""Tests for OpenAI via Anthropic messages interface"""
|
||||
|
||||
@property
|
||||
def model_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"model": "openai/gpt-4.1-mini",
|
||||
"client": None,
|
||||
}
|
||||
|
||||
@property
|
||||
def expected_model_name_in_logging(self) -> str:
|
||||
"""
|
||||
This is the model name that is expected to be in the logging payload
|
||||
"""
|
||||
return "gpt-4.1-mini"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"""
|
||||
E2E Test suite for Anthropic Messages API prompt caching across different providers.
|
||||
|
||||
Tests that prompt caching works correctly via litellm.anthropic.messages interface
|
||||
by making actual API calls and validating usage metrics.
|
||||
|
||||
Per AWS docs (https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html):
|
||||
- Converse API uses: cachePoint: { type: "default" }
|
||||
- InvokeModel API uses: cache_control: { type: "ephemeral" }
|
||||
- Claude 3.7 Sonnet: GA, 1024 min tokens
|
||||
- Claude 3.5 Haiku: GA, 2048 min tokens
|
||||
"""
|
||||
|
||||
|
||||
|
||||
import pytest
|
||||
from base_anthropic_messages_prompt_caching_test import (
|
||||
BaseAnthropicMessagesPromptCachingTest,
|
||||
)
|
||||
|
||||
|
||||
class TestBedrockConversePromptCaching(BaseAnthropicMessagesPromptCachingTest):
|
||||
"""
|
||||
E2E tests for prompt caching with Bedrock Converse API.
|
||||
|
||||
Uses the bedrock/converse/ prefix which routes through litellm.completion()
|
||||
and the AmazonConverseConfig transformation.
|
||||
"""
|
||||
|
||||
def get_model(self) -> str:
|
||||
return "bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
|
||||
|
||||
class TestBedrockInvokePromptCaching(BaseAnthropicMessagesPromptCachingTest):
|
||||
"""
|
||||
E2E tests for prompt caching with Bedrock Invoke API.
|
||||
|
||||
Uses the bedrock/invoke/ prefix which routes through the native
|
||||
Anthropic Messages API format.
|
||||
"""
|
||||
|
||||
def get_model(self) -> str:
|
||||
return "bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"""
|
||||
E2E Test suite for Anthropic Messages API tool search across different providers.
|
||||
|
||||
Tests that tool search works correctly via litellm.anthropic.messages interface
|
||||
by making actual API calls.
|
||||
|
||||
Supported providers:
|
||||
- Anthropic API: advanced-tool-use-2025-11-20
|
||||
- Azure Anthropic: advanced-tool-use-2025-11-20
|
||||
- Vertex AI: tool-search-tool-2025-10-19
|
||||
- Bedrock Invoke: tool-search-tool-2025-10-19
|
||||
|
||||
Reference: https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
|
||||
"""
|
||||
|
||||
|
||||
|
||||
import pytest
|
||||
from base_anthropic_messages_tool_search_test import (
|
||||
BaseAnthropicMessagesToolSearchTest,
|
||||
)
|
||||
|
||||
|
||||
class TestAnthropicAPIToolSearch(BaseAnthropicMessagesToolSearchTest):
|
||||
"""
|
||||
E2E tests for tool search with Anthropic API directly.
|
||||
|
||||
Uses the anthropic/ prefix which routes through the native
|
||||
Anthropic Messages API.
|
||||
|
||||
Beta header: advanced-tool-use-2025-11-20
|
||||
|
||||
Note: Tool search is only supported on Claude Opus 4.5 and Claude Sonnet 4.5.
|
||||
"""
|
||||
|
||||
def get_model(self) -> str:
|
||||
return "anthropic/claude-sonnet-4-5-20250929"
|
||||
|
||||
|
||||
# class TestAzureAnthropicToolSearch(BaseAnthropicMessagesToolSearchTest):
|
||||
# """
|
||||
# E2E tests for tool search with Azure Anthropic (Microsoft Foundry).
|
||||
|
||||
# Uses the azure/ prefix which routes through Azure's Anthropic endpoint.
|
||||
|
||||
# Beta header: advanced-tool-use-2025-11-20
|
||||
# """
|
||||
|
||||
# def get_model(self) -> str:
|
||||
# return "azure/claude-sonnet-4-5-20250929"
|
||||
|
||||
|
||||
# class TestVertexAIToolSearch(BaseAnthropicMessagesToolSearchTest):
|
||||
# """
|
||||
# E2E tests for tool search with Vertex AI.
|
||||
|
||||
# Uses the vertex_ai/ prefix which routes through Google Cloud's
|
||||
# Vertex AI Anthropic partner models.
|
||||
|
||||
# Beta header: tool-search-tool-2025-10-19
|
||||
# """
|
||||
|
||||
# def get_model(self) -> str:
|
||||
# return "vertex_ai/claude-sonnet-4@20250514"
|
||||
|
||||
|
||||
# class TestBedrockInvokeToolSearch(BaseAnthropicMessagesToolSearchTest):
|
||||
# """
|
||||
# E2E tests for tool search with Bedrock Invoke API.
|
||||
|
||||
# Uses the bedrock/invoke/ prefix which routes through the native
|
||||
# Anthropic Messages API format on Bedrock.
|
||||
|
||||
# Beta header: advanced-tool-use-2025-11-20 (passed via extra_headers)
|
||||
|
||||
# Note: Tool search on Bedrock is only supported on Claude Opus 4.5.
|
||||
# """
|
||||
|
||||
# def get_model(self) -> str:
|
||||
# return "bedrock/invoke/us.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator, Dict, Any
|
||||
import asyncio
|
||||
import unittest.mock
|
||||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
from litellm.router import Router
|
||||
|
||||
import litellm
|
||||
from base_anthropic_unified_messages_test import BaseAnthropicMessagesTest
|
||||
|
||||
INSTANCE_BASE_ANTHROPIC_MESSAGES_TEST = BaseAnthropicMessagesTest()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_messages_bedrock_converse_with_thinking():
|
||||
"""
|
||||
Test that bedrock/converse model works with thinking parameter.
|
||||
Validates the request body from issue where budget_tokens was being lost.
|
||||
"""
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"litellm_params": {
|
||||
"model": "bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": "What is 2+2?"}]
|
||||
|
||||
response = await router.aanthropic_messages(
|
||||
messages=messages,
|
||||
model="bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
max_tokens=1026,
|
||||
thinking={"type": "enabled", "budget_tokens": 1025},
|
||||
)
|
||||
print("bedrock response: ", response)
|
||||
|
||||
# Verify response
|
||||
INSTANCE_BASE_ANTHROPIC_MESSAGES_TEST._validate_response(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_should_not_fail_with_forwarded_headers_bedrock_invoke_messages():
|
||||
"""
|
||||
E2E test for Bedrock invoke messages with header forwarding enabled.
|
||||
This calls the real Bedrock endpoint (no mocks) and should not raise
|
||||
SigV4 signature mismatch errors when forwarded headers are present.
|
||||
"""
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "claude-sonnet-4-5-20250929",
|
||||
"litellm_params": {
|
||||
"model": "bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"aws_region_name": os.getenv("AWS_REGION_NAME"),
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
forwarded_headers = {
|
||||
"x-forwarded-for": "10.11.232.194",
|
||||
"x-forwarded-port": "443",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-app": "cli",
|
||||
}
|
||||
|
||||
response = await router.aanthropic_messages(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
max_tokens=5,
|
||||
stream=False,
|
||||
headers=forwarded_headers, # simulates forward_client_headers_to_llm_api
|
||||
aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
aws_region_name=os.getenv("AWS_REGION_NAME"),
|
||||
)
|
||||
print("INVOKE API RESPONSE: ", response)
|
||||
|
||||
INSTANCE_BASE_ANTHROPIC_MESSAGES_TEST._validate_response(response)
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
"""
|
||||
Simple E2E test for Bedrock with advanced-tool-use beta header.
|
||||
|
||||
Tests that LiteLLM correctly filters out the advanced-tool-use-2025-11-20 beta header
|
||||
for Bedrock Invoke API, which doesn't support it and returns a 400 "invalid beta flag" error.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bedrock_sonnet_4_5_with_advanced_tool_use_beta_header():
|
||||
"""
|
||||
Simple E2E test: Call Bedrock Sonnet 4.5 with advanced-tool-use beta header.
|
||||
|
||||
This should work without throwing "invalid beta flag" error because LiteLLM
|
||||
filters out the advanced-tool-use beta header for Bedrock Invoke API.
|
||||
"""
|
||||
litellm.turn_on_debug()
|
||||
response = await litellm.anthropic.messages.acreate(
|
||||
model="bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
messages=[{"role": "user", "content": "What is 2+2?"}],
|
||||
max_tokens=100,
|
||||
provider_specific_header={
|
||||
"custom_llm_provider": "bedrock",
|
||||
"extra_headers": {
|
||||
"anthropic-beta": "advanced-tool-use-2025-11-20",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response is not None
|
||||
assert "content" in response
|
||||
print(f"✅ Test passed! Response: {response}")
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_bedrock_claude_3_5_with_advanced_tool_use_beta_header_filtered():
|
||||
# """
|
||||
# Simple E2E test: Call Bedrock Claude 3.5 with advanced-tool-use beta header.
|
||||
|
||||
# This should work because the beta header is filtered out by LiteLLM before
|
||||
# sending the request to Bedrock Invoke API.
|
||||
# """
|
||||
|
||||
# response = await litellm.anthropic.messages.acreate(
|
||||
# model="bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
# messages=[{"role": "user", "content": "What is 2+2?"}],
|
||||
# max_tokens=100,
|
||||
# provider_specific_header={
|
||||
# "custom_llm_provider": "bedrock",
|
||||
# "extra_headers": {
|
||||
# "anthropic-beta": "advanced-tool-use-2025-11-20",
|
||||
# },
|
||||
# },
|
||||
# )
|
||||
|
||||
# # Verify response
|
||||
# assert response is not None
|
||||
# assert "content" in response
|
||||
# print(f"✅ Test passed! Claude 3.5 response (beta header filtered): {response}")
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
pass_through_request,
|
||||
)
|
||||
|
||||
|
||||
class TestCustomLogger(CustomLogger):
|
||||
def __init__(self):
|
||||
self.logged_kwargs: Optional[dict] = None
|
||||
|
||||
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
print(
|
||||
"in async log success event kwargs",
|
||||
json.dumps(kwargs, indent=4, default=str),
|
||||
)
|
||||
self.logged_kwargs = kwargs
|
||||
|
||||
|
||||
UPSTREAM_RESPONSE_BODY = {
|
||||
"id": "modr-abc123",
|
||||
"model": "omni-moderation-latest",
|
||||
"results": [
|
||||
{
|
||||
"flagged": False,
|
||||
"categories": {"violence": False},
|
||||
"category_scores": {"violence": 1.2e-06},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstream():
|
||||
received: dict = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("content-length", 0) or 0))
|
||||
received["path"] = self.path
|
||||
received["body"] = json.loads(body or b"{}")
|
||||
payload = json.dumps(UPSTREAM_RESPONSE_BODY).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}", received
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_logging_payload_for_a_route_no_provider_handler_claims(
|
||||
upstream,
|
||||
):
|
||||
base_url, upstream_received = upstream
|
||||
|
||||
test_custom_logger = TestCustomLogger()
|
||||
litellm._async_success_callback = [test_custom_logger]
|
||||
|
||||
TARGET_URL = f"{base_url}/v1/moderations"
|
||||
REQUEST_BODY = {
|
||||
"model": "omni-moderation-latest",
|
||||
"input": "I want to bake a cake for my friend's birthday.",
|
||||
}
|
||||
TARGET_METHOD = "POST"
|
||||
|
||||
result = await pass_through_request(
|
||||
request=Request(
|
||||
scope={
|
||||
"type": "http",
|
||||
"method": TARGET_METHOD,
|
||||
"path": "/v1/moderations",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"authorization", b"Bearer sk-test-passthrough"),
|
||||
],
|
||||
},
|
||||
),
|
||||
target=TARGET_URL,
|
||||
custom_headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer sk-test-passthrough",
|
||||
},
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="test",
|
||||
user_id="test",
|
||||
team_id="test",
|
||||
end_user_id="test",
|
||||
),
|
||||
custom_body=REQUEST_BODY,
|
||||
forward_headers=False,
|
||||
merge_query_params=False,
|
||||
)
|
||||
|
||||
print("got result", result)
|
||||
print("result status code", result.status_code)
|
||||
print("result content", result.body)
|
||||
|
||||
assert upstream_received.get("path") == "/v1/moderations"
|
||||
assert upstream_received.get("body") == REQUEST_BODY
|
||||
assert result.status_code == 200
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert test_custom_logger.logged_kwargs is not None
|
||||
passthrough_logging_payload: Optional[PassthroughStandardLoggingPayload] = (
|
||||
test_custom_logger.logged_kwargs["passthrough_logging_payload"]
|
||||
)
|
||||
assert passthrough_logging_payload is not None
|
||||
assert passthrough_logging_payload["url"] == TARGET_URL
|
||||
assert passthrough_logging_payload["request_body"] == REQUEST_BODY
|
||||
assert passthrough_logging_payload["request_method"] == TARGET_METHOD
|
||||
|
||||
client_facing_response_body = json.loads(result.body)
|
||||
assert client_facing_response_body == UPSTREAM_RESPONSE_BODY
|
||||
assert passthrough_logging_payload["response_body"] == client_facing_response_body
|
||||
|
|
@ -1,991 +0,0 @@
|
|||
"""
|
||||
Real E2E Tests for WebSearch Interception
|
||||
|
||||
Makes actual calls to test WebSearch interception with Perplexity.
|
||||
Tests both streaming and non-streaming requests.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.websearch_interception import (
|
||||
WebSearchInterceptionLogger,
|
||||
)
|
||||
from litellm.anthropic_interface import messages
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
||||
async def test_websearch_interception_non_streaming():
|
||||
"""
|
||||
Test WebSearch interception with non-streaming request.
|
||||
Validates that agentic loop executes transparently.
|
||||
"""
|
||||
litellm.turn_on_debug()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("E2E TEST 1: WebSearch Interception (Non-Streaming)")
|
||||
print("=" * 80)
|
||||
|
||||
# Initialize real router with search_tools configuration
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm import Router
|
||||
|
||||
# Create real router with search_tools
|
||||
router = Router(
|
||||
search_tools=[
|
||||
{
|
||||
"search_tool_name": "my-perplexity-search",
|
||||
"litellm_params": {"search_provider": "perplexity"},
|
||||
}
|
||||
]
|
||||
)
|
||||
proxy_server.llm_router = router
|
||||
|
||||
print("\n✅ Initialized router with search_tools:")
|
||||
print(f" - search_tool_name: my-perplexity-search")
|
||||
print(f" - search_provider: perplexity")
|
||||
|
||||
# Enable WebSearch interception for bedrock
|
||||
websearch_logger = WebSearchInterceptionLogger(
|
||||
enabled_providers=[LlmProviders.BEDROCK],
|
||||
search_tool_name="my-perplexity-search",
|
||||
)
|
||||
litellm.callbacks = [websearch_logger]
|
||||
litellm.set_verbose = True
|
||||
|
||||
print("\n✅ Configured WebSearch interception for Bedrock")
|
||||
print("✅ Will use search tool from router")
|
||||
|
||||
try:
|
||||
# Make request with WebSearch tool (non-streaming)
|
||||
print("\n📞 Making litellm.messages.acreate() call...")
|
||||
print(f" Model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
print(f" Query: 'What is LiteLLM?'")
|
||||
print(f" Tools: WebSearch")
|
||||
print(f" Stream: False")
|
||||
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is LiteLLM? Give me a brief overview.",
|
||||
}
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"name": "WebSearch",
|
||||
"description": "Search the web for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
],
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
print("\n✅ Received response!")
|
||||
|
||||
# Handle both dict and object responses
|
||||
if isinstance(response, dict):
|
||||
response_id = response.get("id")
|
||||
response_model = response.get("model")
|
||||
response_stop_reason = response.get("stop_reason")
|
||||
response_content = response.get("content", [])
|
||||
else:
|
||||
response_id = response.id
|
||||
response_model = response.model
|
||||
response_stop_reason = response.stop_reason
|
||||
response_content = response.content
|
||||
|
||||
print(f"\n📄 Response ID: {response_id}")
|
||||
print(f"📄 Model: {response_model}")
|
||||
print(f"📄 Stop Reason: {response_stop_reason}")
|
||||
print(f"📄 Content blocks: {len(response_content)}")
|
||||
|
||||
# Debug: Print all content block types
|
||||
for i, block in enumerate(response_content):
|
||||
block_type = block.get("type") if isinstance(block, dict) else block.type
|
||||
print(f" Block {i}: type={block_type}")
|
||||
if block_type == "tool_use":
|
||||
block_name = (
|
||||
block.get("name") if isinstance(block, dict) else block.name
|
||||
)
|
||||
print(f" name={block_name}")
|
||||
|
||||
# Validate response
|
||||
assert response is not None, "Response should not be None"
|
||||
assert response_content is not None, "Response should have content"
|
||||
assert (
|
||||
len(response_content) > 0
|
||||
), "Response should have at least one content block"
|
||||
|
||||
# Check if response contains tool_use (means interception didn't work)
|
||||
has_tool_use = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "tool_use"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
# Check if we got a text response
|
||||
has_text = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "text"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
if has_tool_use:
|
||||
print("\n❌ TEST 1 FAILED: Interception did not work")
|
||||
print(f"❌ Stop reason: {response_stop_reason}")
|
||||
print("❌ Response contains tool_use blocks")
|
||||
return False
|
||||
|
||||
elif has_text and response_stop_reason != "tool_use":
|
||||
text_block = next(
|
||||
block
|
||||
for block in response_content
|
||||
if (block.get("type") if isinstance(block, dict) else block.type)
|
||||
== "text"
|
||||
)
|
||||
text_content = (
|
||||
text_block.get("text")
|
||||
if isinstance(text_block, dict)
|
||||
else text_block.text
|
||||
)
|
||||
|
||||
print(f"\n📝 Response Text:")
|
||||
print(f" {text_content[:200]}...")
|
||||
|
||||
if "litellm" in text_content.lower():
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ TEST 1 PASSED!")
|
||||
print("=" * 80)
|
||||
print("✅ User made ONE litellm.messages.acreate() call")
|
||||
print("✅ Got back final answer (not tool_use)")
|
||||
print("✅ Agentic loop executed transparently")
|
||||
print("✅ WebSearch interception working!")
|
||||
print("=" * 80)
|
||||
return True
|
||||
else:
|
||||
print("\n⚠️ Got text response but doesn't mention LiteLLM")
|
||||
return False
|
||||
else:
|
||||
print("\n❌ Unexpected response format")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test 1 failed with error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_websearch_interception_streaming():
|
||||
"""
|
||||
Test WebSearch interception with streaming request.
|
||||
Validates that stream=True is converted to stream=False transparently.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("E2E TEST 2: WebSearch Interception (Streaming)")
|
||||
print("=" * 80)
|
||||
|
||||
# Router already initialized from test 1
|
||||
print("\n✅ Using existing router configuration")
|
||||
print("✅ WebSearch interception already enabled for Bedrock")
|
||||
print("✅ Streaming will be converted to non-streaming for WebSearch interception")
|
||||
|
||||
try:
|
||||
# Make request with WebSearch tool AND stream=True
|
||||
print("\n📞 Making litellm.messages.acreate() call with stream=True...")
|
||||
print(f" Model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
print(f" Query: 'What is LiteLLM?'")
|
||||
print(f" Tools: WebSearch")
|
||||
print(f" Stream: True (will be converted to False)")
|
||||
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is LiteLLM? Give me a brief overview.",
|
||||
}
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"name": "WebSearch",
|
||||
"description": "Search the web for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
],
|
||||
max_tokens=1024,
|
||||
stream=True, # REQUEST STREAMING
|
||||
)
|
||||
|
||||
print("\n✅ Received response!")
|
||||
|
||||
# Check if response is actually a stream (async generator)
|
||||
import inspect
|
||||
|
||||
is_stream = inspect.isasyncgen(response)
|
||||
|
||||
if is_stream:
|
||||
print("\n⚠️ WARNING: Response is a stream (async_generator)")
|
||||
print("⚠️ This means stream conversion didn't work!")
|
||||
print("\n📦 Consuming stream chunks:")
|
||||
|
||||
chunks = []
|
||||
chunk_count = 0
|
||||
async for chunk in response:
|
||||
chunk_count += 1
|
||||
print(f"\n--- Chunk {chunk_count} ---")
|
||||
print(chunk)
|
||||
chunks.append(chunk)
|
||||
|
||||
print(
|
||||
f"\n❌ TEST 2 FAILED: Got {len(chunks)} stream chunks instead of single response"
|
||||
)
|
||||
return False
|
||||
|
||||
# If not a stream, validate as normal response
|
||||
print("✅ Response is NOT a stream (conversion worked!)")
|
||||
|
||||
# Handle both dict and object responses
|
||||
if isinstance(response, dict):
|
||||
response_id = response.get("id")
|
||||
response_model = response.get("model")
|
||||
response_stop_reason = response.get("stop_reason")
|
||||
response_content = response.get("content", [])
|
||||
else:
|
||||
response_id = response.id
|
||||
response_model = response.model
|
||||
response_stop_reason = response.stop_reason
|
||||
response_content = response.content
|
||||
|
||||
print(f"\n📄 Response ID: {response_id}")
|
||||
print(f"📄 Model: {response_model}")
|
||||
print(f"📄 Stop Reason: {response_stop_reason}")
|
||||
print(f"📄 Content blocks: {len(response_content)}")
|
||||
|
||||
# Debug: Print all content block types
|
||||
for i, block in enumerate(response_content):
|
||||
block_type = block.get("type") if isinstance(block, dict) else block.type
|
||||
print(f" Block {i}: type={block_type}")
|
||||
|
||||
# Validate response
|
||||
assert response is not None, "Response should not be None"
|
||||
assert response_content is not None, "Response should have content"
|
||||
assert (
|
||||
len(response_content) > 0
|
||||
), "Response should have at least one content block"
|
||||
|
||||
# Check if response contains tool_use (means interception didn't work)
|
||||
has_tool_use = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "tool_use"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
# Check if we got a text response
|
||||
has_text = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "text"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
if has_tool_use:
|
||||
print("\n❌ TEST 2 FAILED: Interception did not work")
|
||||
print("❌ Response contains tool_use blocks")
|
||||
return False
|
||||
|
||||
elif has_text and response_stop_reason != "tool_use":
|
||||
text_block = next(
|
||||
block
|
||||
for block in response_content
|
||||
if (block.get("type") if isinstance(block, dict) else block.type)
|
||||
== "text"
|
||||
)
|
||||
text_content = (
|
||||
text_block.get("text")
|
||||
if isinstance(text_block, dict)
|
||||
else text_block.text
|
||||
)
|
||||
|
||||
print(f"\n📝 Response Text:")
|
||||
print(f" {text_content[:200]}...")
|
||||
|
||||
if "litellm" in text_content.lower():
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ TEST 2 PASSED!")
|
||||
print("=" * 80)
|
||||
print(
|
||||
"✅ User made ONE litellm.messages.acreate() call with stream=True"
|
||||
)
|
||||
print("✅ Stream was transparently converted to non-streaming")
|
||||
print("✅ Got back final answer (not tool_use)")
|
||||
print("✅ Agentic loop executed transparently")
|
||||
print("✅ WebSearch interception working with streaming!")
|
||||
print("=" * 80)
|
||||
return True
|
||||
else:
|
||||
print("\n⚠️ Got text response but doesn't mention LiteLLM")
|
||||
return False
|
||||
else:
|
||||
print("\n❌ Unexpected response format")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test 2 failed with error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_websearch_interception_no_tool_call_streaming():
|
||||
"""
|
||||
Test WebSearch interception when LLM doesn't make a tool call with streaming.
|
||||
|
||||
This tests the scenario where:
|
||||
1. User requests stream=True
|
||||
2. WebSearch tool is provided
|
||||
3. LLM decides NOT to use the tool (just responds with text)
|
||||
4. System should return a fake stream
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("E2E TEST 3: WebSearch Interception (No Tool Call, Streaming)")
|
||||
print("=" * 80)
|
||||
|
||||
# Router already initialized from test 1
|
||||
print("\n✅ Using existing router configuration")
|
||||
print("✅ WebSearch interception already enabled for Bedrock")
|
||||
|
||||
try:
|
||||
# Make request with WebSearch tool AND stream=True
|
||||
# Use a query that the LLM will answer directly without using the tool
|
||||
print("\n📞 Making litellm.messages.acreate() call with stream=True...")
|
||||
print(f" Model: bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
print(f" Query: 'What is 2+2?'")
|
||||
print(f" Tools: WebSearch")
|
||||
print(f" Stream: True")
|
||||
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is 2+2? Just give me the answer, no need to search.",
|
||||
}
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"name": "WebSearch",
|
||||
"description": "Search the web for information",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
],
|
||||
max_tokens=1024,
|
||||
stream=True, # REQUEST STREAMING
|
||||
)
|
||||
|
||||
print("\n✅ Received response!")
|
||||
|
||||
# Check if response is actually a stream (async generator or async iterator)
|
||||
import inspect
|
||||
|
||||
is_async_gen = inspect.isasyncgen(response)
|
||||
is_async_iter = hasattr(response, "__aiter__") and hasattr(
|
||||
response, "__anext__"
|
||||
)
|
||||
is_stream = is_async_gen or is_async_iter
|
||||
|
||||
if not is_stream:
|
||||
print("\n❌ TEST 3 FAILED: Response is NOT a stream")
|
||||
print(f"❌ Expected a fake stream when LLM doesn't use the tool")
|
||||
print(f"❌ Response type: {type(response)}")
|
||||
return False
|
||||
|
||||
print(
|
||||
f"✅ Response is a stream (async_gen={is_async_gen}, async_iter={is_async_iter})"
|
||||
)
|
||||
print("\n📦 Consuming stream chunks:")
|
||||
|
||||
chunks = []
|
||||
chunk_count = 0
|
||||
async for chunk in response:
|
||||
chunk_count += 1
|
||||
print(f"\n--- Chunk {chunk_count} ---")
|
||||
print(f" Type: {type(chunk)}")
|
||||
print(
|
||||
f" Content: {chunk[:200] if isinstance(chunk, bytes) else str(chunk)[:200]}..."
|
||||
)
|
||||
chunks.append(chunk)
|
||||
|
||||
print(f"\n✅ Received {len(chunks)} stream chunk(s)")
|
||||
|
||||
if len(chunks) > 0:
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ TEST 3 PASSED!")
|
||||
print("=" * 80)
|
||||
print("✅ User made ONE litellm.messages.acreate() call with stream=True")
|
||||
print("✅ LLM didn't use the WebSearch tool")
|
||||
print("✅ Got back a fake stream (not a non-streaming response)")
|
||||
print("✅ WebSearch interception handles no-tool-call case correctly!")
|
||||
print("=" * 80)
|
||||
return True
|
||||
else:
|
||||
print("\n❌ TEST 3 FAILED: No chunks received")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test 3 failed with error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_claude_code_native_websearch():
|
||||
"""
|
||||
Test WebSearch interception with Claude Code's native web_search_20250305 tool.
|
||||
|
||||
This tests the exact request format that Claude Code sends:
|
||||
- tools: [{'type': 'web_search_20250305', 'name': 'web_search', 'max_uses': 8}]
|
||||
- Model: bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("E2E TEST: Claude Code Native WebSearch (web_search_20250305)")
|
||||
print("=" * 80)
|
||||
|
||||
# Router already initialized from test 1
|
||||
print("\n✅ Using existing router configuration")
|
||||
print("✅ WebSearch interception already enabled for Bedrock")
|
||||
|
||||
try:
|
||||
# Make request with Claude Code's exact native web_search tool format
|
||||
print("\n📞 Making litellm.messages.acreate() call...")
|
||||
print(f" Model: bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
print(f" Query: 'Perform a web search for the query: litellm what is it'")
|
||||
print(f" Tools: Native web_search_20250305")
|
||||
print(f" Stream: False")
|
||||
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Perform a web search for the query: litellm what is it",
|
||||
}
|
||||
],
|
||||
tools=[
|
||||
{"type": "web_search_20250305", "name": "web_search", "max_uses": 8}
|
||||
],
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
print("\n✅ Received response!")
|
||||
|
||||
# Handle both dict and object responses
|
||||
if isinstance(response, dict):
|
||||
response_id = response.get("id")
|
||||
response_model = response.get("model")
|
||||
response_stop_reason = response.get("stop_reason")
|
||||
response_content = response.get("content", [])
|
||||
else:
|
||||
response_id = response.id
|
||||
response_model = response.model
|
||||
response_stop_reason = response.stop_reason
|
||||
response_content = response.content
|
||||
|
||||
print(f"\n📄 Response ID: {response_id}")
|
||||
print(f"📄 Model: {response_model}")
|
||||
print(f"📄 Stop Reason: {response_stop_reason}")
|
||||
print(f"📄 Content blocks: {len(response_content)}")
|
||||
|
||||
# Debug: Print all content block types
|
||||
for i, block in enumerate(response_content):
|
||||
block_type = block.get("type") if isinstance(block, dict) else block.type
|
||||
print(f" Block {i}: type={block_type}")
|
||||
if block_type == "tool_use":
|
||||
block_name = (
|
||||
block.get("name") if isinstance(block, dict) else block.name
|
||||
)
|
||||
print(f" name={block_name}")
|
||||
|
||||
# Validate response
|
||||
assert response is not None, "Response should not be None"
|
||||
assert response_content is not None, "Response should have content"
|
||||
assert (
|
||||
len(response_content) > 0
|
||||
), "Response should have at least one content block"
|
||||
|
||||
# Check if response contains tool_use (means interception didn't work)
|
||||
has_tool_use = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "tool_use"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
# Check if we got a text response
|
||||
has_text = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "text"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
if has_tool_use:
|
||||
print("\n❌ TEST FAILED: Interception did not work")
|
||||
print(f"❌ Stop reason: {response_stop_reason}")
|
||||
print("❌ Response contains tool_use blocks")
|
||||
return False
|
||||
|
||||
elif has_text and response_stop_reason != "tool_use":
|
||||
text_block = next(
|
||||
block
|
||||
for block in response_content
|
||||
if (block.get("type") if isinstance(block, dict) else block.type)
|
||||
== "text"
|
||||
)
|
||||
text_content = (
|
||||
text_block.get("text")
|
||||
if isinstance(text_block, dict)
|
||||
else text_block.text
|
||||
)
|
||||
|
||||
print(f"\n📝 Response Text:")
|
||||
print(f" {text_content[:200]}...")
|
||||
|
||||
if "litellm" in text_content.lower():
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ TEST PASSED!")
|
||||
print("=" * 80)
|
||||
print(
|
||||
"✅ Claude Code's native web_search_20250305 tool was intercepted"
|
||||
)
|
||||
print("✅ Tool was converted to LiteLLM standard format")
|
||||
print("✅ User made ONE litellm.messages.acreate() call")
|
||||
print("✅ Got back final answer with search results")
|
||||
print("✅ Agentic loop executed transparently")
|
||||
print("✅ WebSearch interception working with Claude Code!")
|
||||
print("=" * 80)
|
||||
return True
|
||||
else:
|
||||
print("\n⚠️ Got text response but doesn't mention LiteLLM")
|
||||
return False
|
||||
else:
|
||||
print("\n❌ Unexpected response format")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test failed with error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
async def run_all_tests():
|
||||
"""Run all E2E tests"""
|
||||
test_results = []
|
||||
|
||||
# Test 1: Non-streaming
|
||||
result1 = await test_websearch_interception_non_streaming()
|
||||
test_results.append(("Non-Streaming", result1))
|
||||
|
||||
# Test 2: Streaming
|
||||
result2 = await test_websearch_interception_streaming()
|
||||
test_results.append(("Streaming", result2))
|
||||
|
||||
# Test 3: No tool call with streaming
|
||||
result3 = await test_websearch_interception_no_tool_call_streaming()
|
||||
test_results.append(("No Tool Call Streaming", result3))
|
||||
|
||||
# Test 4: Claude Code native web_search
|
||||
result4 = await test_claude_code_native_websearch()
|
||||
test_results.append(("Claude Code Native WebSearch", result4))
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 80)
|
||||
print("TEST SUMMARY")
|
||||
print("=" * 80)
|
||||
for test_name, result in test_results:
|
||||
status = "✅ PASSED" if result else "❌ FAILED"
|
||||
print(f"{test_name}: {status}")
|
||||
print("=" * 80)
|
||||
|
||||
# Return overall result
|
||||
return all(result for _, result in test_results)
|
||||
|
||||
result = asyncio.run(run_all_tests())
|
||||
import sys
|
||||
|
||||
sys.exit(0 if result else 1)
|
||||
|
||||
|
||||
async def test_litellm_standard_websearch_tool():
|
||||
"""
|
||||
PRIORITY TEST #1: Test with the canonical litellm_web_search tool format.
|
||||
|
||||
This validates that using get_litellm_web_search_tool() directly
|
||||
works end-to-end without any conversion needed.
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("E2E TEST: LiteLLM Standard WebSearch Tool")
|
||||
print("=" * 80)
|
||||
|
||||
from litellm.integrations.websearch_interception import get_litellm_web_search_tool
|
||||
|
||||
print("\n✅ Using existing router configuration")
|
||||
print("✅ WebSearch interception already enabled for Bedrock")
|
||||
|
||||
try:
|
||||
print("\n📞 Making litellm.messages.acreate() call...")
|
||||
print(f" Model: bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
print(f" Query: 'What is the latest news about AI?'")
|
||||
print(f" Tool: litellm_web_search (standard format, no conversion needed)")
|
||||
print(f" Stream: False")
|
||||
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the latest news about AI? Give me a brief overview.",
|
||||
}
|
||||
],
|
||||
tools=[get_litellm_web_search_tool()],
|
||||
max_tokens=1024,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
print("\n✅ Received response!")
|
||||
|
||||
if isinstance(response, dict):
|
||||
response_id = response.get("id")
|
||||
response_stop_reason = response.get("stop_reason")
|
||||
response_content = response.get("content", [])
|
||||
else:
|
||||
response_id = response.id
|
||||
response_stop_reason = response.stop_reason
|
||||
response_content = response.content
|
||||
|
||||
print(f"\n📄 Response ID: {response_id}")
|
||||
print(f"📄 Stop Reason: {response_stop_reason}")
|
||||
print(f"📄 Content blocks: {len(response_content)}")
|
||||
|
||||
for i, block in enumerate(response_content):
|
||||
block_type = block.get("type") if isinstance(block, dict) else block.type
|
||||
print(f" Block {i}: type={block_type}")
|
||||
|
||||
has_tool_use = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "tool_use"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
has_text = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "text"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
if has_tool_use:
|
||||
print("\n❌ TEST FAILED: Interception did not work")
|
||||
return False
|
||||
|
||||
elif has_text and response_stop_reason != "tool_use":
|
||||
text_block = next(
|
||||
block
|
||||
for block in response_content
|
||||
if (block.get("type") if isinstance(block, dict) else block.type)
|
||||
== "text"
|
||||
)
|
||||
text_content = (
|
||||
text_block.get("text")
|
||||
if isinstance(text_block, dict)
|
||||
else text_block.text
|
||||
)
|
||||
|
||||
print(f"\n📝 Response Text: {text_content[:200]}...")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ TEST PASSED!")
|
||||
print("=" * 80)
|
||||
print("✅ LiteLLM standard tool format works without conversion")
|
||||
print("✅ Agentic loop executed transparently")
|
||||
print("=" * 80)
|
||||
return True
|
||||
else:
|
||||
print("\n❌ Unexpected response format")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test failed with error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_claude_code_native_websearch_streaming():
|
||||
"""
|
||||
PRIORITY TEST #2: Test Claude Code's native tool WITH stream=True.
|
||||
|
||||
Validates:
|
||||
- Native tool conversion (web_search_20250305 → litellm_web_search)
|
||||
- Stream=True → Stream=False conversion
|
||||
- Agentic loop executes with both conversions
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("E2E TEST: Claude Code Native WebSearch + Streaming")
|
||||
print("=" * 80)
|
||||
|
||||
print("\n✅ Using existing router configuration")
|
||||
print("✅ WebSearch interception already enabled for Bedrock")
|
||||
|
||||
try:
|
||||
print("\n📞 Making litellm.messages.acreate() call with stream=True...")
|
||||
print(f" Model: bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
print(f" Tool: Native web_search_20250305")
|
||||
print(f" Stream: True (will be converted to False)")
|
||||
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
messages=[
|
||||
{"role": "user", "content": "Search for the latest AI developments."}
|
||||
],
|
||||
tools=[
|
||||
{"type": "web_search_20250305", "name": "web_search", "max_uses": 8}
|
||||
],
|
||||
max_tokens=1024,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
print("\n✅ Received response!")
|
||||
|
||||
import inspect
|
||||
|
||||
is_stream = inspect.isasyncgen(response)
|
||||
|
||||
if is_stream:
|
||||
print("\n⚠️ Response is a stream (stream conversion didn't work)")
|
||||
return False
|
||||
|
||||
print("✅ Response is NOT a stream (conversion worked!)")
|
||||
|
||||
if isinstance(response, dict):
|
||||
response_stop_reason = response.get("stop_reason")
|
||||
response_content = response.get("content", [])
|
||||
else:
|
||||
response_stop_reason = response.stop_reason
|
||||
response_content = response.content
|
||||
|
||||
has_tool_use = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "tool_use"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
has_text = any(
|
||||
(block.get("type") if isinstance(block, dict) else block.type) == "text"
|
||||
for block in response_content
|
||||
)
|
||||
|
||||
if has_tool_use:
|
||||
print("\n❌ TEST FAILED: Interception did not work")
|
||||
return False
|
||||
|
||||
elif has_text and response_stop_reason != "tool_use":
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ TEST PASSED!")
|
||||
print("=" * 80)
|
||||
print("✅ Native tool converted to litellm_web_search")
|
||||
print("✅ Stream=True converted to Stream=False")
|
||||
print("✅ Both conversions working together!")
|
||||
print("=" * 80)
|
||||
return True
|
||||
else:
|
||||
print("\n❌ Unexpected response format")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test failed with error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
async def test_pre_request_hook_modifies_request_body():
|
||||
"""
|
||||
Unit test to verify async_pre_request_hook correctly modifies request body.
|
||||
|
||||
Tests that:
|
||||
1. WebSearchInterceptionLogger is active
|
||||
2. Native web_search_20250305 tool is converted to litellm_web_search
|
||||
3. Stream is converted from True to False
|
||||
4. Modified parameters reach the API call
|
||||
"""
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME
|
||||
|
||||
litellm.turn_on_debug()
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("UNIT TEST: Pre-Request Hook Modifies Request Body")
|
||||
print("=" * 80)
|
||||
|
||||
# Initialize WebSearchInterceptionLogger
|
||||
litellm.callbacks = [
|
||||
WebSearchInterceptionLogger(
|
||||
enabled_providers=[LlmProviders.BEDROCK],
|
||||
search_tool_name="test-search-tool",
|
||||
)
|
||||
]
|
||||
|
||||
print("✅ WebSearchInterceptionLogger initialized")
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.search_tools = [
|
||||
{
|
||||
"search_tool_name": "test-search-tool",
|
||||
"litellm_params": {"search_provider": "tavily"},
|
||||
}
|
||||
]
|
||||
|
||||
# Track what actually gets sent to the API
|
||||
captured_request = {}
|
||||
|
||||
def mock_anthropic_messages_handler(
|
||||
max_tokens,
|
||||
messages,
|
||||
model,
|
||||
metadata=None,
|
||||
stop_sequences=None,
|
||||
stream=None,
|
||||
system=None,
|
||||
temperature=None,
|
||||
thinking=None,
|
||||
tool_choice=None,
|
||||
tools=None,
|
||||
top_k=None,
|
||||
top_p=None,
|
||||
container=None,
|
||||
api_key=None,
|
||||
api_base=None,
|
||||
client=None,
|
||||
custom_llm_provider=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Mock handler that captures the actual request parameters"""
|
||||
# Capture what gets sent to the handler (after hook modifications)
|
||||
captured_request["tools"] = tools
|
||||
captured_request["stream"] = stream
|
||||
captured_request["max_tokens"] = max_tokens
|
||||
captured_request["model"] = model
|
||||
|
||||
# Return a mock response (non-streaming)
|
||||
from litellm.types.llms.anthropic_messages.anthropic_response import (
|
||||
AnthropicMessagesResponse,
|
||||
)
|
||||
|
||||
return AnthropicMessagesResponse(
|
||||
id="msg_test",
|
||||
type="message",
|
||||
role="assistant",
|
||||
content=[{"type": "text", "text": "Test response"}],
|
||||
model="claude-sonnet-4-5",
|
||||
stop_reason="end_turn",
|
||||
usage={"input_tokens": 10, "output_tokens": 20},
|
||||
)
|
||||
|
||||
# Patch the anthropic_messages_handler function (called after hooks)
|
||||
with patch(
|
||||
"litellm.llms.anthropic.pass_through.messages.handler.anthropic_messages_handler",
|
||||
side_effect=mock_anthropic_messages_handler,
|
||||
), patch( # test-quality-ok: the hook imports this process-global router at call time; no injection seam exists to register search_tools
|
||||
"litellm.proxy.proxy_server.llm_router",
|
||||
mock_router,
|
||||
):
|
||||
|
||||
print(
|
||||
"\n📝 Making request with native web_search_20250305 tool (stream=True)..."
|
||||
)
|
||||
|
||||
# Make the request with native tool format
|
||||
response = await messages.acreate(
|
||||
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
messages=[{"role": "user", "content": "Test query"}],
|
||||
tools=[
|
||||
{"type": "web_search_20250305", "name": "web_search", "max_uses": 8}
|
||||
],
|
||||
max_tokens=100,
|
||||
stream=True, # Should be converted to False
|
||||
)
|
||||
|
||||
print("\n🔍 Verifying request modifications...")
|
||||
|
||||
# Verify tool was converted
|
||||
tools = captured_request.get("tools")
|
||||
print(f"\n Captured tools: {tools}")
|
||||
|
||||
if tools and len(tools) > 0:
|
||||
tool = tools[0]
|
||||
tool_name = tool.get("name")
|
||||
|
||||
if tool_name == LITELLM_WEB_SEARCH_TOOL_NAME:
|
||||
print(
|
||||
f" ✅ Tool converted: web_search_20250305 → {LITELLM_WEB_SEARCH_TOOL_NAME}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f" ❌ Tool NOT converted: expected {LITELLM_WEB_SEARCH_TOOL_NAME}, got {tool_name}"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
print(" ❌ No tools captured in request")
|
||||
return False
|
||||
|
||||
# Verify stream was converted
|
||||
stream = captured_request.get("stream")
|
||||
print(f" Captured stream: {stream}")
|
||||
|
||||
if stream is False:
|
||||
print(" ✅ Stream converted: True → False")
|
||||
else:
|
||||
print(f" ❌ Stream NOT converted: expected False, got {stream}")
|
||||
return False
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ PRE-REQUEST HOOK TEST PASSED!")
|
||||
print("=" * 80)
|
||||
print("✅ CustomLogger is active")
|
||||
print("✅ async_pre_request_hook modifies request body")
|
||||
print("✅ Tool conversion works correctly")
|
||||
print("✅ Stream conversion works correctly")
|
||||
print("=" * 80)
|
||||
|
||||
return True
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
from collections.abc import Iterator
|
||||
from typing import Final, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
from litellm_enterprise.enterprise_callbacks.pagerduty.pagerduty import PagerDutyAlerting
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
PAGERDUTY_URL: Final = "https://events.pagerduty.com/v2/enqueue"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _httpx_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
yield
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
||||
def _pagerduty_logger(monkeypatch: pytest.MonkeyPatch, failure_threshold: int = 1) -> PagerDutyAlerting:
|
||||
monkeypatch.setenv("PAGERDUTY_API_KEY", "unit-routing-key")
|
||||
return PagerDutyAlerting(
|
||||
alerting_args={
|
||||
"failure_threshold": failure_threshold,
|
||||
"failure_threshold_window_seconds": 600,
|
||||
"hanging_threshold_seconds": 0,
|
||||
"hanging_threshold_fails": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(("failure_count", "threshold", "request_count"), [(1, 1, 1), (2, 3, 0), (3, 3, 1)])
|
||||
async def test_failure_alerts_follow_the_configured_threshold(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
respx_mock: respx.MockRouter,
|
||||
failure_count: int,
|
||||
threshold: int,
|
||||
request_count: int,
|
||||
) -> None:
|
||||
route: Final = respx_mock.post(PAGERDUTY_URL).mock(return_value=httpx.Response(202))
|
||||
logger: Final = _pagerduty_logger(monkeypatch, threshold)
|
||||
payload: Final = cast(
|
||||
StandardLoggingPayload,
|
||||
{
|
||||
"error_information": {
|
||||
"error_class": "RateLimitError",
|
||||
"error_code": "429",
|
||||
"llm_provider": "openai",
|
||||
},
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
|
||||
for _ in range(failure_count):
|
||||
await logger.async_log_failure_event({"standard_logging_object": payload}, None, None, None)
|
||||
|
||||
assert route.call_count == request_count
|
||||
if request_count == 1:
|
||||
body: Final = TypeAdapter(dict[str, object]).validate_json(route.calls.last.request.content)
|
||||
payload_body: Final = TypeAdapter(dict[str, object]).validate_python(body["payload"])
|
||||
details: Final = TypeAdapter(dict[str, object]).validate_python(payload_body["custom_details"])
|
||||
recent_errors: Final = TypeAdapter(list[dict[str, object]]).validate_python(details["recent_errors"])
|
||||
assert body["routing_key"] == "unit-routing-key"
|
||||
assert body["event_action"] == "trigger"
|
||||
assert payload_body["summary"] == f"High LLM API Failure Rate: {failure_count} in the last 600 seconds."
|
||||
assert recent_errors[0]["error_class"] == "RateLimitError"
|
||||
assert recent_errors[0]["error_code"] == "429"
|
||||
assert recent_errors[0]["error_llm_provider"] == "openai"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hanging_requests_alert_at_the_default_threshold(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
route: Final = respx_mock.post(PAGERDUTY_URL).mock(return_value=httpx.Response(202))
|
||||
logger: Final = _pagerduty_logger(monkeypatch)
|
||||
user: Final = UserAPIKeyAuth(api_key="unit-key", key_alias="unit-alias", user_id="unit-user")
|
||||
|
||||
for _ in range(60):
|
||||
await logger.hanging_response_handler(
|
||||
{"request_id": "request-1", "litellm_call_id": "unit-call"},
|
||||
user,
|
||||
)
|
||||
|
||||
assert route.call_count == 1
|
||||
body: Final = TypeAdapter(dict[str, object]).validate_json(route.calls.last.request.content)
|
||||
payload_body: Final = TypeAdapter(dict[str, object]).validate_python(body["payload"])
|
||||
details: Final = TypeAdapter(dict[str, object]).validate_python(payload_body["custom_details"])
|
||||
recent_errors: Final = TypeAdapter(list[dict[str, object]]).validate_python(details["recent_errors"])
|
||||
assert body["routing_key"] == "unit-routing-key"
|
||||
assert payload_body["summary"] == "High Number of Hanging LLM Requests: 60 in the last 600 seconds."
|
||||
assert recent_errors[0]["failure_event_type"] == "hanging_response"
|
||||
|
|
@ -1,14 +1,40 @@
|
|||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from typing import List, Optional, Tuple
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Final, List, Optional, Tuple, cast
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from pydantic import TypeAdapter
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CachedVertexCredentials:
|
||||
token: str
|
||||
quota_project_id: str | None
|
||||
expired: bool = False
|
||||
|
||||
def refresh(self, request: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _httpx_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
yield
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -62,3 +88,169 @@ async def test_construct_request_headers_project_id_from_env(monkeypatch):
|
|||
finally:
|
||||
# Clean up environment variable
|
||||
del os.environ["GCS_PUBSUB_PROJECT_ID"]
|
||||
|
||||
|
||||
def _authorized_user_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, project_id: str) -> str:
|
||||
credentials_path: Final = tmp_path / "google-auth.json"
|
||||
credentials_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "authorized_user",
|
||||
"client_id": "unit-client",
|
||||
"client_secret": "unit-secret",
|
||||
"refresh_token": "unit-refresh-token",
|
||||
"token": "unit-access-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"quota_project_id": "unit-project",
|
||||
"expiry": "2099-01-01T00:00:00Z",
|
||||
}
|
||||
)
|
||||
)
|
||||
credentials_path_string: Final = str(credentials_path)
|
||||
credentials: Final = _CachedVertexCredentials(token="unit-access-token", quota_project_id=project_id)
|
||||
vertex_chat_completion: Final = litellm.vertex_chat_completion
|
||||
monkeypatch.setitem(
|
||||
vertex_chat_completion._credentials_project_mapping,
|
||||
(credentials_path_string, project_id),
|
||||
(credentials, project_id),
|
||||
)
|
||||
return credentials_path_string
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_message_encodes_the_standard_payload(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, tmp_path: Path
|
||||
) -> None:
|
||||
from litellm.integrations.gcs_pubsub.pub_sub import GcsPubSubLogger
|
||||
|
||||
project_id: Final = "unit-project"
|
||||
topic_id: Final = "unit-topic"
|
||||
url: Final = f"https://pubsub.googleapis.com/v1/projects/{project_id}/topics/{topic_id}:publish"
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
route: Final = respx_mock.post(url).mock(return_value=httpx.Response(202, json={"messageIds": ["publish-1"]}))
|
||||
logger: Final = GcsPubSubLogger(
|
||||
project_id=project_id,
|
||||
topic_id=topic_id,
|
||||
credentials_path=_authorized_user_credentials(monkeypatch, tmp_path, project_id),
|
||||
)
|
||||
payload: Final = cast(
|
||||
StandardLoggingPayload,
|
||||
{
|
||||
"request_id": "request-1",
|
||||
"model": "vertex_ai/gemini-2.5-flash",
|
||||
"messages": [{"role": "user", "content": "publish this"}],
|
||||
"response": {"choices": [{"message": {"content": "published"}}]},
|
||||
},
|
||||
)
|
||||
|
||||
response: Final = TypeAdapter(dict[str, list[str]]).validate_python(await logger.publish_message(payload))
|
||||
request_json: Final = TypeAdapter(dict[str, list[dict[str, str]]]).validate_json(route.calls.last.request.content)
|
||||
decoded_message: Final = TypeAdapter(dict[str, object]).validate_json(
|
||||
base64.b64decode(request_json["messages"][0]["data"])
|
||||
)
|
||||
|
||||
assert route.call_count == 1
|
||||
assert route.calls.last.request.url == url
|
||||
assert route.calls.last.request.headers["Authorization"] == "Bearer unit-access-token"
|
||||
assert request_json == {
|
||||
"messages": [
|
||||
{
|
||||
"data": base64.b64encode(json.dumps(payload, default=str).encode("utf-8")).decode("utf-8"),
|
||||
}
|
||||
]
|
||||
}
|
||||
assert decoded_message == payload
|
||||
assert response == {"messageIds": ["publish-1"]}
|
||||
|
||||
|
||||
def _without_volatile_gcs_payload_fields(value: object, path: str = "") -> object:
|
||||
ignored_paths: Final = frozenset(
|
||||
{
|
||||
"request_id",
|
||||
"litellm_call_id",
|
||||
"metadata.litellm_call_id",
|
||||
"session_id",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"completionStartTime",
|
||||
"request_duration_ms",
|
||||
"metadata.model_map_information",
|
||||
"metadata.usage_object",
|
||||
"metadata.cold_storage_object_key",
|
||||
"metadata.litellm_overhead_time_ms",
|
||||
"metadata.cost_breakdown",
|
||||
"metadata.autorouter_savings",
|
||||
"metadata.eval_information",
|
||||
}
|
||||
)
|
||||
if isinstance(value, Mapping):
|
||||
mapping: Final = TypeAdapter(dict[str, object]).validate_python(value)
|
||||
return MappingProxyType(
|
||||
{
|
||||
key: _without_volatile_gcs_payload_fields(item, f"{path}.{key}" if path else key)
|
||||
for key, item in mapping.items()
|
||||
if (f"{path}.{key}" if path else key) not in ignored_paths
|
||||
}
|
||||
)
|
||||
if isinstance(value, list):
|
||||
items: Final = TypeAdapter(list[object]).validate_python(value)
|
||||
return tuple(
|
||||
_without_volatile_gcs_payload_fields(item, f"{path}[{index}]") for index, item in enumerate(items)
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_v1_success_event_publishes_the_spend_log_fixture(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, tmp_path: Path
|
||||
) -> None:
|
||||
from litellm.integrations.gcs_pubsub.pub_sub import GcsPubSubLogger
|
||||
|
||||
project_id: Final = "unit-project"
|
||||
topic_id: Final = "unit-topic"
|
||||
url: Final = f"https://pubsub.googleapis.com/v1/projects/{project_id}/topics/{topic_id}:publish"
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
monkeypatch.setattr(litellm, "gcs_pub_sub_use_v1", True)
|
||||
route: Final = respx_mock.post(url).mock(return_value=httpx.Response(202, json={"messageIds": ["publish-2"]}))
|
||||
logger: Final = GcsPubSubLogger(
|
||||
project_id=project_id,
|
||||
topic_id=topic_id,
|
||||
credentials_path=_authorized_user_credentials(monkeypatch, tmp_path, project_id),
|
||||
)
|
||||
standard_logging_payload: Final = cast(
|
||||
StandardLoggingPayload,
|
||||
{
|
||||
"model": "gpt-4o",
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
"metadata": {},
|
||||
"hidden_params": {},
|
||||
"model_map_information": None,
|
||||
},
|
||||
)
|
||||
response_obj: Final = {
|
||||
"id": "unit-response",
|
||||
"model": "gpt-4o",
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
"choices": [{"message": {"content": "hi"}}],
|
||||
}
|
||||
await logger.async_log_success_event(
|
||||
kwargs={"model": "gpt-4o", "standard_logging_object": standard_logging_payload},
|
||||
response_obj=response_obj,
|
||||
start_time=datetime.datetime(2025, 1, 1),
|
||||
end_time=datetime.datetime(2025, 1, 1, 0, 0, 1),
|
||||
)
|
||||
await logger.async_send_batch()
|
||||
|
||||
request_json: Final = TypeAdapter(dict[str, list[dict[str, str]]]).validate_json(route.calls.last.request.content)
|
||||
decoded_payload: Final = TypeAdapter(dict[str, object]).validate_json(
|
||||
base64.b64decode(request_json["messages"][0]["data"])
|
||||
)
|
||||
|
||||
assert route.call_count == 1
|
||||
assert route.calls.last.request.url == url
|
||||
assert decoded_payload["model"] == "gpt-4o"
|
||||
assert decoded_payload["prompt_tokens"] == 1
|
||||
assert decoded_payload["completion_tokens"] == 1
|
||||
assert decoded_payload["total_tokens"] == 2
|
||||
|
|
|
|||
|
|
@ -21,10 +21,17 @@
|
|||
"extra_body": "{}"
|
||||
},
|
||||
"langfuse.observation.type": "generation",
|
||||
"langfuse.observation.output": {
|
||||
"content": "Hello! How can I assist you today?",
|
||||
"role": "assistant",
|
||||
"tool_calls": null,
|
||||
"function_call": null,
|
||||
"provider_specific_fields": null
|
||||
},
|
||||
"langfuse.observation.usage_details": {
|
||||
"input": 10,
|
||||
"output": 10,
|
||||
"total": 20,
|
||||
"output": 20,
|
||||
"total": 30,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0
|
||||
},
|
||||
|
|
@ -16,7 +16,8 @@
|
|||
"langfuse.observation.level": "DEFAULT",
|
||||
"langfuse.observation.model.name": "gpt-3.5-turbo",
|
||||
"langfuse.observation.model.parameters": {
|
||||
"extra_body": "{}"
|
||||
"extra_body": "{}",
|
||||
"stream": true
|
||||
},
|
||||
"langfuse.observation.output": {
|
||||
"content": "Hello! How can I assist you today?",
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import Iterator, Mapping
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
|
||||
from opentelemetry.proto.common.v1.common_pb2 import AnyValue
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.caching.llm_caching_handler import LLMClientCache
|
||||
from litellm.integrations.langfuse.langfuse import LangFuseLogger
|
||||
from litellm.integrations.langfuse.langfuse_prompt_management import langfuse_client_init
|
||||
from litellm.integrations.langfuse.langfuse_sdk import (
|
||||
flush_langfuse_tracing,
|
||||
resolve_observation_id,
|
||||
resolve_trace_id,
|
||||
)
|
||||
from litellm.litellm_core_utils import litellm_logging
|
||||
from litellm.litellm_core_utils.litellm_logging import DynamicLoggingCache as LegacyDynamicLoggingCache
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.litellm_core_utils.thread_pool_executor import MAX_THREADS, executor
|
||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||
|
||||
_EXPORT_URL: Final = "https://langfuse.unit/api/public/otel/v1/traces"
|
||||
_VOLATILE_ATTRIBUTES: Final = frozenset(
|
||||
{
|
||||
"langfuse.observation.completion_start_time",
|
||||
"langfuse.observation.cost_details",
|
||||
"langfuse.observation.metadata.applied_guardrails",
|
||||
"langfuse.observation.metadata.cache_hit",
|
||||
"langfuse.observation.metadata.hidden_params",
|
||||
"langfuse.observation.metadata.litellm_call_id",
|
||||
"langfuse.observation.metadata.litellm_response_cost",
|
||||
"langfuse.observation.metadata.requester_metadata",
|
||||
"langfuse.observation.metadata.response_id",
|
||||
"langfuse.observation.metadata.usage_object",
|
||||
"langfuse.observation.usage_details",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _decode_attribute(value: AnyValue) -> object:
|
||||
match value.WhichOneof("value"):
|
||||
case "string_value":
|
||||
try:
|
||||
return json.loads(value.string_value)
|
||||
except json.JSONDecodeError:
|
||||
return value.string_value
|
||||
case "bool_value":
|
||||
return value.bool_value
|
||||
case "int_value":
|
||||
return value.int_value
|
||||
case "double_value":
|
||||
return value.double_value
|
||||
case "array_value":
|
||||
return [_decode_attribute(item) for item in value.array_value.values]
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _exported_spans(route: respx.Route) -> tuple[dict[str, object], ...]:
|
||||
exported: Final = tuple(
|
||||
ExportTraceServiceRequest.FromString(call.request.content)
|
||||
for call in route.calls
|
||||
)
|
||||
return tuple(
|
||||
{
|
||||
"trace_id": span.trace_id.hex(),
|
||||
"span_id": span.span_id.hex(),
|
||||
"parent_span_id": span.parent_span_id.hex() or None,
|
||||
"name": span.name,
|
||||
"attributes": {
|
||||
attribute.key: _decode_attribute(attribute.value)
|
||||
for attribute in span.attributes
|
||||
},
|
||||
}
|
||||
for request in exported
|
||||
for resource in request.resource_spans
|
||||
for scope in resource.scope_spans
|
||||
for span in scope.spans
|
||||
)
|
||||
|
||||
|
||||
def _comparable(span: Mapping[str, object]) -> dict[str, object]:
|
||||
attributes: Final = TypeAdapter(dict[str, object]).validate_python(span["attributes"])
|
||||
return {
|
||||
"name": span["name"],
|
||||
"parent_span_id": span["parent_span_id"],
|
||||
"attributes": {
|
||||
key: value for key, value in sorted(attributes.items()) if key not in _VOLATILE_ATTRIBUTES
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _expected_generation(route: respx.Route, fixture_name: str, trace_id: str) -> dict[str, object]:
|
||||
otel_trace_id: Final = resolve_trace_id(trace_id)
|
||||
generations: Final = tuple(
|
||||
span
|
||||
for span in _exported_spans(route)
|
||||
if span["trace_id"] == otel_trace_id
|
||||
and TypeAdapter(dict[str, object]).validate_python(span["attributes"]).get("langfuse.observation.type")
|
||||
== "generation"
|
||||
)
|
||||
assert len(generations) == 1
|
||||
expected_path: Final = Path(__file__).with_name("langfuse_expected_request_body") / fixture_name
|
||||
expected: Final = TypeAdapter(dict[str, object]).validate_python(json.loads(expected_path.read_text()))
|
||||
actual: Final = _comparable(generations[0])
|
||||
attributes: Final = TypeAdapter(dict[str, object]).validate_python(generations[0]["attributes"])
|
||||
cost_details: Final = TypeAdapter(dict[str, float]).validate_python(attributes["langfuse.observation.cost_details"])
|
||||
usage_details: Final = TypeAdapter(dict[str, int]).validate_python(attributes["langfuse.observation.usage_details"])
|
||||
assert cost_details["total"] >= 0
|
||||
assert usage_details["input"] >= 0
|
||||
assert usage_details["output"] >= 0
|
||||
assert usage_details["total"] >= usage_details["input"]
|
||||
assert usage_details["total"] >= usage_details["output"]
|
||||
expected_comparable: Final = _comparable(expected)
|
||||
assert actual == expected_comparable, json.dumps(
|
||||
{"actual": actual, "expected": expected_comparable}, indent=2, sort_keys=True
|
||||
)
|
||||
return actual
|
||||
|
||||
|
||||
def _drain_logging_executor() -> None:
|
||||
workers: Final = MAX_THREADS
|
||||
gate: Final = threading.Barrier(workers + 1)
|
||||
futures: Final = tuple(executor.submit(gate.wait) for _ in range(workers))
|
||||
gate.wait()
|
||||
for future in futures:
|
||||
future.result()
|
||||
|
||||
|
||||
async def _flush_langfuse() -> None:
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
scheduled_callbacks: Final[asyncio.Future[None]] = loop.create_future()
|
||||
loop.call_soon(scheduled_callbacks.set_result, None)
|
||||
await scheduled_callbacks
|
||||
await GLOBAL_LOGGING_WORKER.flush()
|
||||
_drain_logging_executor()
|
||||
assert flush_langfuse_tracing()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def langfuse_export(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
) -> Iterator[tuple[respx.Route, str]]:
|
||||
trace_id: Final = f"litellm-migration-{uuid4()}"
|
||||
route: Final = respx_mock.post(_EXPORT_URL).respond(status_code=200)
|
||||
monkeypatch.setenv("LANGFUSE_HOST", "https://langfuse.unit")
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", f"{trace_id}-public")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", f"{trace_id}-secret")
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
client_cache: Final = LLMClientCache()
|
||||
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", client_cache)
|
||||
http_client: Final = httpx.Client(transport=httpx.MockTransport(respx_mock.handler))
|
||||
client_cache.set_cache(
|
||||
key="httpx_client",
|
||||
value=HTTPHandler(client=http_client),
|
||||
litellm_owned_client=False,
|
||||
)
|
||||
monkeypatch.setattr(litellm, "success_callback", ["langfuse"])
|
||||
monkeypatch.setattr(litellm_logging, "langFuseLogger", None)
|
||||
monkeypatch.setattr(litellm_logging, "in_memory_dynamic_logger_cache", LegacyDynamicLoggingCache())
|
||||
monkeypatch.setattr(litellm_logging, "_in_memory_loggers", [])
|
||||
langfuse_client_init.cache_clear()
|
||||
yield route, trace_id
|
||||
logger: Final = litellm_logging.in_memory_dynamic_logger_cache.get_cache(
|
||||
credentials={}, service_name="langfuse"
|
||||
)
|
||||
if isinstance(logger, LangFuseLogger):
|
||||
logger.stop()
|
||||
client_cache.flush_cache()
|
||||
http_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion(langfuse_export: tuple[respx.Route, str]) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={"trace_id": trace_id},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion_with_tags(langfuse_export: tuple[respx.Route, str]) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={"trace_id": trace_id, "tags": ["test_tag", "test_tag_2"]},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_tags.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion_with_tags_stream(langfuse_export: tuple[respx.Route, str]) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
response: Final = await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={"trace_id": trace_id, "tags": ["test_tag_stream", "test_tag_2_stream"]},
|
||||
stream=True,
|
||||
)
|
||||
chunks: Final = tuple([chunk async for chunk in response])
|
||||
assert chunks
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_tags_stream.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_generation_id_metadata_names_the_exported_observation(
|
||||
langfuse_export: tuple[respx.Route, str],
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={"trace_id": trace_id, "generation_id": "my-generation"},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
generation: Final = next(span for span in _exported_spans(route) if span["trace_id"] == resolve_trace_id(trace_id))
|
||||
assert generation["span_id"] == resolve_observation_id("my-generation")
|
||||
_expected_generation(route, "completion.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion_with_langfuse_metadata(
|
||||
langfuse_export: tuple[respx.Route, str],
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={
|
||||
"trace_id": trace_id,
|
||||
"tags": ["test_tag", "test_tag_2"],
|
||||
"generation_name": "test_generation_name",
|
||||
"parent_observation_id": "test_parent_observation_id",
|
||||
"version": "test_version",
|
||||
"trace_user_id": "test_user_id",
|
||||
"session_id": "test_session_id",
|
||||
"trace_name": "test_trace_name",
|
||||
"trace_metadata": {"test_key": "test_value"},
|
||||
"trace_version": "test_trace_version",
|
||||
"trace_release": "test_trace_release",
|
||||
},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_langfuse_metadata.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_with_non_serializable_metadata(
|
||||
langfuse_export: tuple[respx.Route, str],
|
||||
) -> None:
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
class UserPreferences(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
favorite_colors: frozenset[str]
|
||||
settings: dict[str, object]
|
||||
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={
|
||||
"user_prefs": UserPreferences(favorite_colors=frozenset({"red", "blue"}), settings={"theme": "dark"}),
|
||||
"nested_set": {"inner_set": frozenset({1, 2, 3})},
|
||||
"trace_id": trace_id,
|
||||
},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_complex_metadata.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("metadata", "fixture_name"),
|
||||
[
|
||||
({"a": 1, "b": 2, "c": 3}, "simple_metadata.json"),
|
||||
({"a": {"nested_a": 1}, "b": {"nested_b": 2}}, "nested_metadata.json"),
|
||||
({"a": [1, 2, 3], "b": {4, 5, 6}}, "simple_metadata2.json"),
|
||||
({"a": (1, 2), "b": frozenset({3, 4}), "c": {"d": [5, 6]}}, "simple_metadata3.json"),
|
||||
({"lock": "non-copyable"}, "metadata_with_lock.json"),
|
||||
({"func": "callable"}, "metadata_with_function.json"),
|
||||
({"list": ["list", "not", "a", "dict"]}, "complex_metadata_2.json"),
|
||||
({}, "empty_metadata.json"),
|
||||
],
|
||||
)
|
||||
async def test_langfuse_logging_with_various_metadata_types(
|
||||
langfuse_export: tuple[respx.Route, str], metadata: dict[str, object], fixture_name: str
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response="Hello! How can I assist you today?",
|
||||
metadata={**metadata, "trace_id": trace_id},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, fixture_name, trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion_with_malformed_llm_response(
|
||||
langfuse_export: tuple[respx.Route, str],
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
response: Final = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(prompt_tokens=10, completion_tokens=10, total_tokens=20),
|
||||
model="gpt-3.5-turbo",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=response,
|
||||
metadata={"trace_id": trace_id},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_no_choices.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion_with_bedrock_llm_response(
|
||||
langfuse_export: tuple[respx.Route, str],
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
response: Final = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(prompt_tokens=10, completion_tokens=10, total_tokens=20),
|
||||
model="anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await litellm.acompletion(
|
||||
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=response,
|
||||
metadata={"trace_id": trace_id},
|
||||
aws_access_key_id="fake-key",
|
||||
aws_secret_access_key="fake-key",
|
||||
aws_region="us-east-1",
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_bedrock_call.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_completion_with_vertex_llm_response(
|
||||
langfuse_export: tuple[respx.Route, str],
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
response: Final = litellm.ModelResponse(
|
||||
choices=[],
|
||||
usage=litellm.Usage(prompt_tokens=10, completion_tokens=10, total_tokens=20),
|
||||
model="vertex/gemini-3-flash-preview",
|
||||
object="chat.completion",
|
||||
created=1723081200,
|
||||
).model_dump()
|
||||
await litellm.acompletion(
|
||||
model="vertex_ai/gemini-3-flash-preview",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
mock_response=response,
|
||||
metadata={"trace_id": trace_id},
|
||||
vertex_credentials="my-mock-credentials",
|
||||
api_key="my-mock-credentials-2",
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_vertex_call.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_vllm_embedding(
|
||||
langfuse_export: tuple[respx.Route, str], respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
embedding_url: Final = "http://my-fake-vllm.com/v1/embeddings"
|
||||
upstream: Final = respx_mock.post(embedding_url).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
|
||||
"model": "BAAI/bge-small-en-v1.5",
|
||||
"usage": {"prompt_tokens": 10, "total_tokens": 10},
|
||||
},
|
||||
)
|
||||
)
|
||||
await litellm.aembedding(
|
||||
model="hosted_vllm/BAAI/bge-small-en-v1.5",
|
||||
input=["Hello from litellm!"],
|
||||
api_base="http://my-fake-vllm.com/v1",
|
||||
metadata={"trace_id": trace_id},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert upstream.call_count == 1
|
||||
expected_path: Final = Path(__file__).with_name("langfuse_expected_request_body") / "embedding_with_vllm.json"
|
||||
assert TypeAdapter(dict[str, object]).validate_json(upstream.calls.last.request.content) == json.loads(
|
||||
expected_path.read_text()
|
||||
)
|
||||
assert route.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_logging_with_router(langfuse_export: tuple[respx.Route, str]) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"mock_response": "Hello! How can I assist you today?",
|
||||
"api_key": "test_api_key",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
await router.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
metadata={"trace_id": trace_id},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
_expected_generation(route, "completion_with_router.json", trace_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_langfuse_e2e_sync(langfuse_export: tuple[respx.Route, str]) -> None:
|
||||
route, trace_id = langfuse_export
|
||||
await litellm.acompletion(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "sync callback route"}],
|
||||
mock_response="Hello from litellm",
|
||||
metadata={"trace_id": trace_id},
|
||||
)
|
||||
await _flush_langfuse()
|
||||
assert route.call_count == 1
|
||||
assert str(route.calls[0].request.url).endswith("/api/public/otel/v1/traces")
|
||||
assert _exported_spans(route)
|
||||
|
|
@ -2,37 +2,67 @@ import asyncio
|
|||
import base64
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final, cast
|
||||
from urllib.parse import parse_qs
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.sqs import SQSLogger
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _httpx_transport(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
yield
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
||||
def _fixed_signing_time() -> datetime:
|
||||
return datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_batch_does_not_await_send_directly(monkeypatch):
|
||||
async def test_async_send_batch_dispatches_queued_payload(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
monkeypatch.setattr("botocore.auth.get_current_datetime", _fixed_signing_time)
|
||||
monkeypatch.setattr("litellm.aws_sqs_callback_params", {})
|
||||
spawned = []
|
||||
real_create_task = asyncio.create_task
|
||||
queue_url: Final = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
|
||||
sent: Final = asyncio.Event()
|
||||
payload: Final = cast(
|
||||
StandardLoggingPayload,
|
||||
{
|
||||
"model": "openai/gpt-5.5",
|
||||
"messages": [{"role": "user", "content": "queued request"}],
|
||||
},
|
||||
)
|
||||
|
||||
def spy_create_task(coro, *args, **kwargs):
|
||||
task = real_create_task(coro, *args, **kwargs)
|
||||
spawned.append(task)
|
||||
return task
|
||||
async def respond(_request: httpx.Request) -> httpx.Response:
|
||||
sent.set()
|
||||
return httpx.Response(200)
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_task", spy_create_task)
|
||||
route: Final = respx_mock.post(queue_url).mock(side_effect=respond)
|
||||
logger: Final = SQSLogger(
|
||||
sqs_queue_url=queue_url,
|
||||
sqs_region_name="us-east-1",
|
||||
sqs_aws_access_key_id="unit-access-key",
|
||||
sqs_aws_secret_access_key="unit-secret-key",
|
||||
)
|
||||
logger.log_queue = [payload]
|
||||
|
||||
logger = SQSLogger(sqs_queue_url="https://example.com", sqs_region_name="us-west-2")
|
||||
logger.async_send_message = AsyncMock()
|
||||
logger.log_queue = [{"log": 1}, {"log": 2}]
|
||||
await logger.async_send_batch()
|
||||
await sent.wait()
|
||||
|
||||
try:
|
||||
await logger.async_send_batch()
|
||||
assert logger.async_send_message.await_count == 0
|
||||
finally:
|
||||
for task in spawned:
|
||||
task.cancel()
|
||||
await asyncio.gather(*spawned, return_exceptions=True)
|
||||
assert route.call_count == 1
|
||||
assert json.loads(parse_qs(route.calls.last.request.content.decode())["MessageBody"][0]) == payload
|
||||
|
||||
|
||||
def test_sqs_logger_init_without_encryption(monkeypatch):
|
||||
|
|
@ -139,6 +169,89 @@ async def test_strip_base64_removes_file_and_nontext_entries():
|
|||
assert content.get("type") == "text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_message_posts_the_queue_url_and_encoded_payload(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
monkeypatch.setattr("botocore.auth.get_current_datetime", _fixed_signing_time)
|
||||
monkeypatch.setattr("litellm.aws_sqs_callback_params", {})
|
||||
monkeypatch.setattr(asyncio, "create_task", MagicMock())
|
||||
queue_url: Final = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
|
||||
payload: Final = cast(
|
||||
StandardLoggingPayload,
|
||||
{
|
||||
"model": "openai/gpt-5.5",
|
||||
"messages": [{"role": "user", "content": "log this request"}],
|
||||
"response": {"choices": [{"message": {"content": "logged"}}]},
|
||||
},
|
||||
)
|
||||
route: Final = respx_mock.post(queue_url).mock(return_value=httpx.Response(200))
|
||||
logger: Final = SQSLogger(
|
||||
sqs_queue_url=queue_url,
|
||||
sqs_region_name="us-east-1",
|
||||
sqs_aws_access_key_id="unit-access-key",
|
||||
sqs_aws_secret_access_key="unit-secret-key",
|
||||
)
|
||||
|
||||
await logger.async_send_message(payload)
|
||||
|
||||
body: Final = parse_qs(route.calls.last.request.content.decode())
|
||||
assert route.call_count == 1
|
||||
assert body["Action"] == ["SendMessage"]
|
||||
assert body["Version"] == ["2012-11-05"]
|
||||
assert json.loads(body["MessageBody"][0]) == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_send_message_handles_a_rejected_queue_request(
|
||||
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
||||
) -> None:
|
||||
monkeypatch.setattr("botocore.auth.get_current_datetime", _fixed_signing_time)
|
||||
monkeypatch.setattr("litellm.aws_sqs_callback_params", {})
|
||||
monkeypatch.setattr(asyncio, "create_task", MagicMock())
|
||||
queue_url: Final = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
|
||||
payload: Final = cast(
|
||||
StandardLoggingPayload,
|
||||
{
|
||||
"model": "openai/gpt-5.5",
|
||||
"messages": [{"role": "user", "content": "log this request"}],
|
||||
"response": {"choices": [{"message": {"content": "logged"}}]},
|
||||
},
|
||||
)
|
||||
route: Final = respx_mock.post(queue_url).mock(return_value=httpx.Response(403, text="rejected"))
|
||||
logger: Final = SQSLogger(
|
||||
sqs_queue_url=queue_url,
|
||||
sqs_region_name="us-east-1",
|
||||
sqs_aws_access_key_id="unit-access-key",
|
||||
sqs_aws_secret_access_key="unit-secret-key",
|
||||
)
|
||||
|
||||
await logger.async_send_message(payload)
|
||||
|
||||
body: Final = parse_qs(route.calls.last.request.content.decode())
|
||||
assert route.call_count == 1
|
||||
assert json.loads(body["MessageBody"][0]) == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strip_base64_returns_text_only_messages_unchanged() -> None:
|
||||
logger: Final = SQSLogger(sqs_strip_base64_files=True)
|
||||
payload: Final = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello world"},
|
||||
{"type": "text", "text": "Keep this sentence"},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Response"}]},
|
||||
]
|
||||
}
|
||||
|
||||
assert await logger._strip_base64_from_messages(payload) == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strip_base64_handles_empty_or_missing_messages():
|
||||
logger = SQSLogger(sqs_strip_base64_files=True)
|
||||
|
|
@ -211,27 +324,3 @@ async def test_strip_base64_recursive_redaction():
|
|||
if isinstance(c, dict):
|
||||
s = json.dumps(c).lower()
|
||||
assert "base64," not in s, f"Found real base64 blob in: {s}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_health_check_healthy(monkeypatch):
|
||||
monkeypatch.setattr("litellm.aws_sqs_callback_params", {})
|
||||
monkeypatch.setattr(asyncio, "create_task", MagicMock())
|
||||
logger = SQSLogger(sqs_queue_url="https://example.com", sqs_region_name="us-west-2")
|
||||
logger.async_send_message = AsyncMock(return_value=None)
|
||||
|
||||
result = await logger.async_health_check()
|
||||
assert result["status"] == "healthy"
|
||||
assert result.get("error_message") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_health_check_unhealthy(monkeypatch):
|
||||
monkeypatch.setattr("litellm.aws_sqs_callback_params", {})
|
||||
monkeypatch.setattr(asyncio, "create_task", MagicMock())
|
||||
logger = SQSLogger(sqs_queue_url="https://example.com", sqs_region_name="us-west-2")
|
||||
logger.async_send_message = AsyncMock(side_effect=Exception("boom"))
|
||||
|
||||
result = await logger.async_health_check()
|
||||
assert result["status"] == "unhealthy"
|
||||
assert "boom" in (result.get("error_message") or "")
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ async def test_completion_with_vector_store_ids_prepends_the_kb_context_block(re
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_completion_carries_the_search_results_on_a_chunk_delta(respx_mock: respx.MockRouter) -> None:
|
||||
_kb_route(respx_mock)
|
||||
respx_mock.post(_ANTHROPIC_URL).mock(
|
||||
kb: Final = _kb_route(respx_mock)
|
||||
anthropic: Final = respx_mock.post(_ANTHROPIC_URL).mock(
|
||||
return_value=httpx.Response(200, text=_ANTHROPIC_STREAM, headers={"content-type": "text/event-stream"})
|
||||
)
|
||||
|
||||
|
|
@ -128,6 +128,8 @@ async def test_streaming_completion_carries_the_search_results_on_a_chunk_delta(
|
|||
if choice.delta.provider_specific_fields and "search_results" in choice.delta.provider_specific_fields
|
||||
)
|
||||
|
||||
assert kb.call_count == 1
|
||||
assert anthropic.call_count == 1
|
||||
assert len(chunks) > 0
|
||||
assert len(annotated) >= 1
|
||||
assert annotated[0][0]["object"] == "vector_store.search_results.page"
|
||||
|
|
@ -137,7 +139,9 @@ async def test_streaming_completion_carries_the_search_results_on_a_chunk_delta(
|
|||
@pytest.mark.asyncio
|
||||
async def test_file_search_filters_reach_the_kb_as_a_bedrock_equals_filter(respx_mock: respx.MockRouter) -> None:
|
||||
kb: Final = _kb_route(respx_mock)
|
||||
respx_mock.post(_ANTHROPIC_URL).mock(return_value=httpx.Response(200, json=_ANTHROPIC_MESSAGE))
|
||||
anthropic: Final = respx_mock.post(_ANTHROPIC_URL).mock(
|
||||
return_value=httpx.Response(200, json=_ANTHROPIC_MESSAGE)
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="anthropic/claude-haiku-4-5-20251001",
|
||||
|
|
@ -156,6 +160,15 @@ async def test_file_search_filters_reach_the_kb_as_a_bedrock_equals_filter(respx
|
|||
_sent_body(kb)["retrievalConfiguration"]
|
||||
)
|
||||
assert retrieval["vectorSearchConfiguration"]["filter"] == {"equals": {"key": "user_id", "value": "fake-user-id"}}
|
||||
assert _sent_body(anthropic)["messages"] == [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": f"{_PREFIX}{_KB_TEXT}\n\n"},
|
||||
{"type": "text", "text": "what is litellm?"},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert response.choices[0].message.content == "LiteLLM simplifies LLM access."
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4241,6 +4241,32 @@ def test_thinking_with_max_completion_tokens():
|
|||
assert result["thinking"]["budget_tokens"] == 5000
|
||||
|
||||
|
||||
def test_anthropic_messages_converse_thinking_budget_is_preserved_in_request_body():
|
||||
model = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
config = AmazonConverseConfig()
|
||||
optional_params = config.map_openai_params(
|
||||
non_default_params={
|
||||
"thinking": {"type": "enabled", "budget_tokens": 1025},
|
||||
"max_tokens": 1026,
|
||||
},
|
||||
optional_params={},
|
||||
model=model,
|
||||
drop_params=False,
|
||||
)
|
||||
request = config._transform_request(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "What is 2+2?"}],
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["additionalModelRequestFields"]["thinking"] == {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 1025,
|
||||
}
|
||||
|
||||
|
||||
def test_drop_thinking_param_when_thinking_blocks_missing():
|
||||
"""
|
||||
Test that thinking param is dropped when modify_params=True and
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.proxy.pass_through_endpoints.streaming_handler import (
|
||||
|
|
@ -16,7 +18,10 @@ from litellm.proxy.pass_through_endpoints.streaming_handler import (
|
|||
from litellm.proxy.pass_through_endpoints.success_handler import (
|
||||
PassThroughEndpointLogging,
|
||||
)
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
EndpointType,
|
||||
PassthroughStandardLoggingPayload,
|
||||
)
|
||||
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
||||
|
||||
|
||||
|
|
@ -203,6 +208,65 @@ async def test_handle_logging_runs_async_handler_for_passthrough():
|
|||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_drain_logging_worker", "_vcr_outcome_gate")
|
||||
@pytest.mark.asyncio
|
||||
async def test_unhandled_route_passes_passthrough_payload_to_custom_logger():
|
||||
class _PayloadLogger(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.payload: PassthroughStandardLoggingPayload | None = None
|
||||
|
||||
async def async_log_success_event(
|
||||
self,
|
||||
kwargs: dict[str, object],
|
||||
response_obj: object,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> None:
|
||||
self.payload = TypeAdapter(PassthroughStandardLoggingPayload).validate_python(
|
||||
kwargs["passthrough_logging_payload"]
|
||||
)
|
||||
|
||||
start_time: Final = datetime(2025, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||
end_time: Final = datetime(2025, 1, 2, 3, 4, 9, tzinfo=timezone.utc)
|
||||
url: Final = "https://upstream.example/v1/moderations"
|
||||
request_body: Final = {"model": "omni-moderation-latest", "input": "check this"}
|
||||
response_body: Final = {"id": "moderation-1", "results": [{"flagged": False}]}
|
||||
payload: Final = PassthroughStandardLoggingPayload(
|
||||
url=url,
|
||||
request_body=request_body,
|
||||
request_method="POST",
|
||||
response_body=response_body,
|
||||
)
|
||||
logger: Final = _PayloadLogger()
|
||||
logging_obj: Final = LiteLLMLoggingObj(
|
||||
model="omni-moderation-latest",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type="pass_through_endpoint",
|
||||
start_time=start_time,
|
||||
litellm_call_id="moderation-1",
|
||||
function_id="moderations",
|
||||
dynamic_async_success_callbacks=[logger],
|
||||
)
|
||||
response: Final = httpx.Response(200, request=httpx.Request("POST", url), json=response_body)
|
||||
|
||||
await PassThroughEndpointLogging().pass_through_async_success_handler(
|
||||
httpx_response=response,
|
||||
response_body=response_body,
|
||||
logging_obj=logging_obj,
|
||||
url_route="/v1/moderations",
|
||||
result="",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
cache_hit=False,
|
||||
request_body=request_body,
|
||||
passthrough_logging_payload=payload,
|
||||
)
|
||||
|
||||
assert logger.payload == payload
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_drain_logging_worker", "_vcr_outcome_gate")
|
||||
def test_convert_raw_bytes_to_str_lines():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue