mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-25 00:23:04 +00:00
* test: modernize models used in CircleCI e2e test suites
Replaces obsolete models (gpt-4o, gpt-4o-mini, gpt-3.5-turbo,
claude-3-5-sonnet-20240620, claude-sonnet-4-20250514) with current
equivalents across the e2e_openai_endpoints and
proxy_e2e_anthropic_messages_tests CircleCI jobs.
- gpt-4o -> gpt-5.5 (responses API e2e tests)
- gpt-4o-mini -> gpt-5-mini (websocket responses, oai_misc_config)
- gpt-4o-mini-2024-07-18 -> gpt-4.1-mini-2025-04-14 (fine-tuning,
still actively fine-tunable)
- gpt-4 / gpt-3.5-turbo target_model_names example -> gpt-5.5 /
gpt-5-mini
- bedrock claude-3-5-sonnet-20240620 batch entry -> haiku-4-5-20251001
(also aligning oai_misc_config model_name with what
test_bedrock_batches_api.py actually requests)
- bedrock claude-sonnet-4-20250514 (deprecated, retires 2026-06-15)
-> claude-sonnet-4-5-20250929
* test: point bedrock-claude-sonnet-4 alias at Sonnet 4.6, not 4.5
Greptile/Cursor flagged that after the previous commit, the
bedrock-claude-sonnet-4 alias collided with bedrock-claude-sonnet-4.5
(both pointed to claude-sonnet-4-5-20250929). Rename to
bedrock-claude-sonnet-4.6 and point it at the Sonnet 4.6 Bedrock ID
(us.anthropic.claude-sonnet-4-6, already in the litellm model
registry) so the alias name matches the underlying model version.
* test: modernize models across remaining CI-mounted configs & tests
Expands the modernization sweep to all CircleCI-mounted proxy configs
and to test directories where the model literal is a fixture/route key
(not the test's subject).
Config changes:
- proxy_server_config.yaml: bump gpt-3.5-turbo / gpt-3.5-turbo-1106 /
gpt-4o / gemini-1.5-flash / dall-e-3 underlying models; rename
gpt-3.5-turbo-end-user-test alias to gpt-5-mini-end-user-test; bump
text-embedding-ada-002 underlying to text-embedding-3-small. User-
facing aliases (gpt-3.5-turbo, gpt-4, text-embedding-ada-002, etc.)
preserved for backward compatibility with tests.
- simple_config.yaml, otel_test_config.yaml, spend_tracking_config.yaml:
bump gpt-3.5-turbo underlying to gpt-5-mini.
- pass_through_config.yaml: claude-3-5-sonnet / claude-3-7-sonnet /
claude-3-haiku entries replaced with claude-sonnet-4-5 / claude-
haiku-4-5 / claude-opus-4-7.
- oai_misc_config.yaml: align alias name with the gpt-5-mini rename.
Test changes (proactive: claude-sonnet-4-20250514 / claude-opus-4-
20250514 retire 2026-06-15):
- tests/llm_translation/test_anthropic_completion.py: bump 3 references
+ paired Vertex AI ID to claude-sonnet-4-5.
- tests/llm_translation/test_optional_params.py: bump 2 references.
- tests/pass_through_unit_tests/test_anthropic_messages_passthrough.py
and test_bedrock_anthropic_messages_test.py: bump router fixtures
using the deprecated model IDs.
- tests/pass_through_unit_tests/base_anthropic_messages_tool_search_test.py:
modernize docstring examples.
- tests/test_end_users.py: update references to renamed alias.
* test: modernize placeholder model literals in router_unit_tests
Mass replace_all on fixture/placeholder model literals across the
router_unit_tests/ suite (model name is a routing key / label, not the
test subject). Sub-agent sweep so far — additional commits will follow
for logging_callback_tests/, enterprise/, top-level tests/test_*.py,
and other CI-mounted dirs.
Mappings applied:
- gpt-3.5-turbo -> gpt-5-mini
- gpt-4 (bare) -> gpt-5.5
- gpt-4o (bare) -> gpt-5
- text-embedding-ada-002 -> text-embedding-3-small
- claude-3-sonnet-20240229 / claude-3-opus-20240229 /
claude-3-haiku-20240307 / claude-3-5-sonnet-20240620 ->
claude-sonnet-4-5-20250929 / claude-opus-4-7 /
claude-haiku-4-5-20251001 as appropriate
Explicitly preserved:
- gpt-4o-mini-* variants (transcribe, tts, etc.) where they're current
- gpt-4-turbo / gpt-4-vision-preview / gpt-4-0613 (subject literals)
- JSONL batch body literals
- Mock LLM response model fields (must match upstream)
- Fake/mock identifiers
* test: modernize placeholder model literals across remaining CI suites
Sub-agent sweep across logging_callback_tests/, guardrails_tests/,
enterprise/, pass_through_unit_tests/, otel_tests/,
llm_responses_api_testing/, batches_tests/, spend_tracking_tests/,
litellm_utils_tests/, unified_google_tests/, and a few top-level
tests/test_*.py files where the model literal is a fixture or
placeholder (router model_list, mock standard logging payload, mock
callback data) rather than the test's subject.
Mappings applied (see scope notes below):
- gpt-3.5-turbo -> gpt-5-mini
- gpt-4 (bare) -> gpt-5.5
- gpt-4o (bare) -> gpt-5.5 (corrected from initial gpt-5 — bare gpt-5
is not a valid OpenAI alias; only gpt-5.5 / gpt-5.4 / gpt-5.2-codex
/ gpt-5-mini exist)
- gpt-4o-mini (bare) -> gpt-5-mini
- text-embedding-ada-002 -> text-embedding-3-small
- claude-3-sonnet-20240229 -> claude-sonnet-4-5-20250929
- claude-3-opus-20240229 -> claude-opus-4-7
- claude-3-haiku-20240307 -> claude-haiku-4-5-20251001
- claude-3-5-sonnet-20240620/20241022 -> claude-sonnet-4-5-20250929
- claude-3-7-sonnet-20250219 -> claude-sonnet-4-6
- gemini-1.5-flash -> gemini-2.5-flash
- gemini-1.5-pro -> gemini-2.5-pro
Explicitly preserved (not modernized):
- llm_translation/ tests where model is the SUBJECT (provider-specific
translation/transformation logic). Only the deprecated 20250514
references were already bumped in a prior commit.
- Cost-calc / tokenizer subject tests in test_utils.py (skip-ranges
documented by the sub-agent).
- Bedrock model IDs in test_health_check.py path-stripping tests.
- JSONL batch request bodies and mock LLM response bodies (must match
upstream literal).
- Langfuse expected-request-body JSON fixtures (cost values are exact-
match-asserted; changing the model would shift response_cost).
- gpt-3.5-turbo-instruct (text-completion endpoint; no modern OpenAI
equivalent).
- Top-level tests calling the proxy through user-facing aliases
(gpt-3.5-turbo, gpt-4, text-embedding-ada-002, dall-e-3) — aliases
in proxy_server_config.yaml stay; only the underlying model was
bumped.
- tests/test_gpt5_azure_temperature_support.py (the test's whole point
is model-name handling).
- Fake / mock / openai/fake identifiers.
Notable side fixes:
- test_spend_accuracy_tests.py: UPSTREAM_MODEL now matches what
spend_tracking_config.yaml's proxy actually routes to (gpt-5-mini),
resolving a latent inconsistency.
- proxy_server_config.yaml: bare `gpt-5` alias renamed to `gpt-5.5`
(bare gpt-5 is not a valid OpenAI alias).
- test_batches_logging_unit_tests.py: explicit_models list entries
kept distinct (gpt-5-mini + gpt-5.5) after bulk rename.
* test: fix CI failures from model modernization sweep
CI surfaced 4 categories of regression from the bulk modernization:
1. Azure deployment names are customer-specific. Reverted:
- tests/litellm_utils_tests/test_health_check.py: azure/text-
embedding-3-small -> azure/text-embedding-ada-002 (the CI Azure
account does not have a text-embedding-3-small deployment).
- tests/logging_callback_tests/test_custom_callback_router.py:
same revert for two router fixtures driving aembedding.
2. gpt-5 family does not accept temperature != 1. Tests that pass a
custom temperature swapped from gpt-5-mini to gpt-4.1-mini (modern
non-reasoning OpenAI mini that still accepts temperature/logprobs):
- tests/logging_callback_tests/test_datadog.py
- tests/logging_callback_tests/test_langsmith_unit_test.py
- tests/logging_callback_tests/test_otel_logging.py
3. proxy_server_config.yaml's gpt-3.5-turbo-large alias was routing to
gpt-5.5 (a reasoning model that rejects logprobs). The proxy test
tests/test_openai_endpoints.py::test_chat_completion_streaming
exercises logprobs/top_logprobs through that alias. Bumped the
underlying model to gpt-4.1 (non-reasoning, still modern).
4. tests/logging_callback_tests/test_gcs_pub_sub.py asserts against a
pinned JSON fixture (gcs_pub_sub_body/spend_logs_payload.json) with
hardcoded model="gpt-4o" and a model-specific spend value. Reverted
the litellm.acompletion calls in the test to model="gpt-4o" so the
fixture's exact-match assertions still hold.
5. tests/pass_through_unit_tests/test_anthropic_messages_passthrough.py:
anthropic.messages.create routing to openai/gpt-5-mini returned an
empty content[0] with max_tokens=100 (reasoning-token consumption).
Swapped to openai/gpt-4.1-mini.
* test: fix Assistants API model + 2 cursor[bot] review nits
1. pass_through_unit_tests/test_custom_logger_passthrough.py: gpt-5.5
isn't accepted by the /v1/assistants endpoint
("unsupported_model"). Switch to gpt-4.1-mini (modern, Assistants-
API-supported, non-reasoning).
2. example_config_yaml/pass_through_config.yaml: the previous sweep
bumped the claude-3-7-sonnet alias to claude-opus-4-7, which is a
tier change (Sonnet -> Opus). Map to claude-sonnet-4-6 to keep the
Sonnet tier intact. (Cursor bugbot review.)
3. example_config_yaml/simple_config.yaml: model_name was left as
gpt-3.5-turbo while the underlying was bumped to gpt-5-mini, which
muddles the "simple" example. Make both sides gpt-5-mini so the
most basic example is a straight 1:1 mapping again. (Cursor bugbot
review.)
* fix: revert gpt-4/gpt-3.5-turbo alias underlying to non-reasoning models
tests/test_openai_endpoints.py::test_completion calls the proxy alias
"gpt-4" with temperature=0, and other tests call gpt-3.5-turbo with
custom temperature / logprobs / the legacy /v1/completions endpoint.
The earlier modernization mapped both aliases to gpt-5.5 / gpt-5-mini,
which are reasoning models that reject temperature != 1 and don't
expose /v1/completions. Map the aliases to gpt-4.1 / gpt-4.1-mini
(modern non-reasoning OpenAI models) instead — keeps user-facing
aliases preserved while picking a current underlying that still
supports the parameters/endpoints the tests exercise.
920 lines
35 KiB
Python
920 lines
35 KiB
Python
"""
|
|
Unit tests for prometheus metrics
|
|
"""
|
|
|
|
import pytest
|
|
import aiohttp
|
|
import asyncio
|
|
from litellm._uuid import uuid
|
|
import os
|
|
import sys
|
|
from openai import AsyncOpenAI
|
|
from typing import Dict, Any
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
END_USER_ID = "my-test-user-34"
|
|
|
|
|
|
async def make_bad_chat_completion_request(session, key):
|
|
url = "http://0.0.0.0:4000/chat/completions"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
data = {
|
|
"model": "fake-azure-endpoint",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
}
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
status = response.status
|
|
response_text = await response.text()
|
|
return status, response_text
|
|
|
|
|
|
async def make_good_chat_completion_request(session, key):
|
|
url = "http://0.0.0.0:4000/chat/completions"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
data = {
|
|
"model": "fake-openai-endpoint",
|
|
"messages": [{"role": "user", "content": f"Hello {uuid.uuid4()}"}],
|
|
"tags": ["teamB"],
|
|
"user": END_USER_ID, # test if disable end user tracking for prometheus works
|
|
}
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
status = response.status
|
|
response_text = await response.text()
|
|
return status, response_text
|
|
|
|
|
|
async def make_chat_completion_request_with_fallback(session, key):
|
|
url = "http://0.0.0.0:4000/chat/completions"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
data = {
|
|
"model": "fake-azure-endpoint",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"fallbacks": ["fake-openai-endpoint"],
|
|
}
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
status = response.status
|
|
response_text = await response.text()
|
|
|
|
# make a request with a failed fallback
|
|
data = {
|
|
"model": "fake-azure-endpoint",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"fallbacks": ["unknown-model"],
|
|
}
|
|
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
status = response.status
|
|
response_text = await response.text()
|
|
|
|
return
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_failure_metrics():
|
|
"""
|
|
- Make 1 bad chat completion call to "fake-azure-endpoint"
|
|
- GET /metrics
|
|
- assert the failure metric for the requested model is incremented by 1
|
|
- Assert the Exception class and status code are correct
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Make a bad chat completion call
|
|
status, response_text = await make_bad_chat_completion_request(
|
|
session, "sk-1234"
|
|
)
|
|
|
|
# Check if the request failed as expected
|
|
assert status == 429, f"Expected status 429, but got {status}"
|
|
|
|
# Get metrics
|
|
async with session.get("http://0.0.0.0:4000/metrics") as response:
|
|
metrics = await response.text()
|
|
|
|
print("/metrics", metrics)
|
|
|
|
# Check if the failure metric is present and correct - use pattern matching for robustness
|
|
# Labels are ordered alphabetically by Prometheus: api_key_alias, end_user, exception_class,
|
|
# exception_status, hashed_api_key, requested_model, route, team, team_alias, user, user_email
|
|
# Note: client_ip, user_agent, model_id are present but we use substring matching to be flexible
|
|
# Check for both the new metric and deprecated metric for backwards compatibility
|
|
expected_patterns = [
|
|
"litellm_proxy_failed_requests_metric_total{", # New metric
|
|
"litellm_llm_api_failed_requests_metric_total{", # Deprecated but may still be used
|
|
]
|
|
|
|
# Master-key auth substitutes LITELLM_PROXY_MASTER_KEY_ALIAS for
|
|
# hash_token(master_key) so the master key (or its hash) never
|
|
# propagates into metrics. See PR #26484.
|
|
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
|
|
|
expected_hashed_api_key = LITELLM_PROXY_MASTER_KEY_ALIAS
|
|
|
|
# Check if either pattern is in metrics and contains required fields
|
|
found_metric = False
|
|
for pattern in expected_patterns:
|
|
for line in metrics.split("\n"):
|
|
# For proxy metric, check proxy-specific fields
|
|
if "litellm_proxy_failed_requests_metric_total{" in line:
|
|
if (
|
|
'api_key_alias="None"' in line
|
|
and 'exception_class="Openai.RateLimitError"' in line
|
|
and 'exception_status="429"' in line
|
|
and f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'requested_model="fake-azure-endpoint"' in line
|
|
and 'route="/chat/completions"' in line
|
|
):
|
|
found_metric = True
|
|
break
|
|
# For deprecated llm_api metric, check llm-specific fields
|
|
elif "litellm_llm_api_failed_requests_metric_total{" in line:
|
|
if (
|
|
f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'model="429"' in line
|
|
): # The deprecated metric uses the actual model from the request
|
|
found_metric = True
|
|
break
|
|
if found_metric:
|
|
break
|
|
|
|
assert (
|
|
found_metric
|
|
), f"Expected failure metric not found in /metrics. Looking for either litellm_proxy_failed_requests_metric_total or litellm_llm_api_failed_requests_metric_total with required fields"
|
|
|
|
# Check total requests metric similarly
|
|
# The litellm_proxy_total_requests_metric_total should be present
|
|
total_requests_pattern = "litellm_proxy_total_requests_metric_total{"
|
|
|
|
found_total_metric = False
|
|
for line in metrics.split("\n"):
|
|
if (
|
|
total_requests_pattern in line
|
|
and f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'requested_model="fake-azure-endpoint"' in line
|
|
and 'status_code="429"' in line
|
|
):
|
|
found_total_metric = True
|
|
break
|
|
|
|
assert (
|
|
found_total_metric
|
|
), f"Expected total requests metric not found in /metrics. Looking for: {total_requests_pattern} with hashed_api_key and status_code=429"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.flaky(retries=3, delay=2)
|
|
async def test_proxy_success_metrics():
|
|
"""
|
|
Make 1 good /chat/completions call to "openai/gpt-5-mini"
|
|
GET /metrics
|
|
Assert the success metric is incremented by 1
|
|
"""
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
# Make a good chat completion call
|
|
status, response_text = await make_good_chat_completion_request(
|
|
session, "sk-1234"
|
|
)
|
|
|
|
# Check if the request succeeded as expected
|
|
assert status == 200, f"Expected status 200, but got {status}"
|
|
|
|
# Get metrics
|
|
async with session.get("http://0.0.0.0:4000/metrics") as response:
|
|
metrics = await response.text()
|
|
|
|
print("/metrics", metrics)
|
|
|
|
assert END_USER_ID not in metrics
|
|
|
|
# Master-key auth substitutes LITELLM_PROXY_MASTER_KEY_ALIAS for
|
|
# hash_token(master_key) (PR #26484).
|
|
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
|
|
|
expected_hashed_api_key = LITELLM_PROXY_MASTER_KEY_ALIAS
|
|
|
|
# Check if the success metric is present and correct - use flexible matching
|
|
# Check for request_total_latency_metric with required fields
|
|
# Note: The model can be "gpt-3.5-turbo-0301" or similar depending on what's returned
|
|
found_request_latency = False
|
|
for line in metrics.split("\n"):
|
|
if (
|
|
"litellm_request_total_latency_metric_bucket{" in line
|
|
and 'api_key_alias="None"' in line
|
|
and f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'requested_model="fake-openai-endpoint"' in line
|
|
and 'le="0.005"' in line
|
|
):
|
|
found_request_latency = True
|
|
break
|
|
|
|
assert (
|
|
found_request_latency
|
|
), "Expected litellm_request_total_latency_metric_bucket not found in /metrics"
|
|
|
|
# Check for llm_api_latency_metric with required fields
|
|
found_api_latency = False
|
|
for line in metrics.split("\n"):
|
|
if (
|
|
"litellm_llm_api_latency_metric_bucket{" in line
|
|
and 'api_key_alias="None"' in line
|
|
and f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'requested_model="fake-openai-endpoint"' in line
|
|
and 'le="0.005"' in line
|
|
):
|
|
found_api_latency = True
|
|
break
|
|
|
|
assert (
|
|
found_api_latency
|
|
), "Expected litellm_llm_api_latency_metric_bucket not found in /metrics"
|
|
|
|
verify_latency_metrics(metrics)
|
|
|
|
|
|
def verify_latency_metrics(metrics: str):
|
|
"""
|
|
Assert that LATENCY_BUCKETS distribution is used for
|
|
- litellm_request_total_latency_metric_bucket
|
|
- litellm_llm_api_latency_metric_bucket
|
|
|
|
Very important to verify that the overhead latency metric is present
|
|
"""
|
|
from litellm.types.integrations.prometheus import LATENCY_BUCKETS
|
|
import re
|
|
import time
|
|
|
|
time.sleep(2)
|
|
|
|
metric_names = [
|
|
"litellm_request_total_latency_metric_bucket",
|
|
"litellm_llm_api_latency_metric_bucket",
|
|
"litellm_overhead_latency_metric_bucket",
|
|
]
|
|
|
|
for metric_name in metric_names:
|
|
# Extract all 'le' values for the current metric
|
|
pattern = rf'{metric_name}{{.*?le="(.*?)".*?}}'
|
|
le_values = re.findall(pattern, metrics)
|
|
|
|
# Convert to set for easier comparison
|
|
actual_buckets = set(le_values)
|
|
|
|
print("actual_buckets", actual_buckets)
|
|
expected_buckets = []
|
|
for bucket in LATENCY_BUCKETS:
|
|
expected_buckets.append(str(bucket))
|
|
|
|
# replace inf with +Inf
|
|
expected_buckets = [
|
|
bucket.replace("inf", "+Inf") for bucket in expected_buckets
|
|
]
|
|
|
|
print("expected_buckets", expected_buckets)
|
|
expected_buckets = set(expected_buckets)
|
|
# Verify all expected buckets are present
|
|
assert (
|
|
actual_buckets == expected_buckets
|
|
), f"Mismatch in {metric_name} buckets. Expected: {expected_buckets}, Got: {actual_buckets}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_fallback_metrics():
|
|
"""
|
|
Make 1 request with a client side fallback - check metrics
|
|
"""
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
# Make a good chat completion call
|
|
await make_chat_completion_request_with_fallback(session, "sk-1234")
|
|
|
|
# Get metrics
|
|
async with session.get("http://0.0.0.0:4000/metrics") as response:
|
|
metrics = await response.text()
|
|
|
|
print("/metrics", metrics)
|
|
|
|
# Master-key auth substitutes LITELLM_PROXY_MASTER_KEY_ALIAS for
|
|
# hash_token(master_key) (PR #26484).
|
|
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
|
|
|
expected_hashed_api_key = LITELLM_PROXY_MASTER_KEY_ALIAS
|
|
|
|
# Check if successful fallback metric is incremented - use flexible matching
|
|
found_successful_fallback = False
|
|
for line in metrics.split("\n"):
|
|
if (
|
|
"litellm_deployment_successful_fallbacks_total{" in line
|
|
and 'api_key_alias="None"' in line
|
|
and 'exception_class="Openai.RateLimitError"' in line
|
|
and 'exception_status="429"' in line
|
|
and 'fallback_model="fake-openai-endpoint"' in line
|
|
and f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'requested_model="fake-azure-endpoint"' in line
|
|
and "1.0" in line
|
|
):
|
|
found_successful_fallback = True
|
|
break
|
|
|
|
assert (
|
|
found_successful_fallback
|
|
), "Expected litellm_deployment_successful_fallbacks_total metric not found in /metrics"
|
|
|
|
# Check if failed fallback metric is incremented - use flexible matching
|
|
found_failed_fallback = False
|
|
for line in metrics.split("\n"):
|
|
if (
|
|
"litellm_deployment_failed_fallbacks_total{" in line
|
|
and 'api_key_alias="None"' in line
|
|
and 'exception_class="Openai.RateLimitError"' in line
|
|
and 'exception_status="429"' in line
|
|
and 'fallback_model="unknown-model"' in line
|
|
and f'hashed_api_key="{expected_hashed_api_key}"' in line
|
|
and 'requested_model="fake-azure-endpoint"' in line
|
|
and "1.0" in line
|
|
):
|
|
found_failed_fallback = True
|
|
break
|
|
|
|
assert (
|
|
found_failed_fallback
|
|
), "Expected litellm_deployment_failed_fallbacks_total metric not found in /metrics"
|
|
|
|
|
|
async def create_test_team(
|
|
session: aiohttp.ClientSession, team_data: Dict[str, Any]
|
|
) -> str:
|
|
"""Create a new team and return the team_id"""
|
|
url = "http://0.0.0.0:4000/team/new"
|
|
headers = {
|
|
"Authorization": "Bearer sk-1234",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async with session.post(url, headers=headers, json=team_data) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to create team. Status: {response.status}"
|
|
team_info = await response.json()
|
|
return team_info["team_id"]
|
|
|
|
|
|
async def create_test_user(
|
|
session: aiohttp.ClientSession, user_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Create a new user and return the user info"""
|
|
url = "http://0.0.0.0:4000/user/new"
|
|
headers = {
|
|
"Authorization": "Bearer sk-1234",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async with session.post(url, headers=headers, json=user_data) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to create user. Status: {response.status}"
|
|
user_info = await response.json()
|
|
return user_info
|
|
|
|
|
|
async def get_prometheus_metrics(session: aiohttp.ClientSession) -> str:
|
|
"""Fetch current prometheus metrics"""
|
|
async with session.get("http://0.0.0.0:4000/metrics") as response:
|
|
assert response.status == 200
|
|
return await response.text()
|
|
|
|
|
|
def extract_budget_metrics(metrics_text: str, team_id: str) -> Dict[str, float]:
|
|
"""Extract budget-related metrics for a specific team"""
|
|
import re
|
|
|
|
metrics = {}
|
|
|
|
# Get remaining budget
|
|
remaining_pattern = f'litellm_remaining_team_budget_metric{{team="{team_id}",team_alias="[^"]*"}} ([0-9.]+)'
|
|
remaining_match = re.search(remaining_pattern, metrics_text)
|
|
metrics["remaining"] = float(remaining_match.group(1)) if remaining_match else None
|
|
|
|
# Get total budget
|
|
total_pattern = f'litellm_team_max_budget_metric{{team="{team_id}",team_alias="[^"]*"}} ([0-9.]+)'
|
|
total_match = re.search(total_pattern, metrics_text)
|
|
metrics["total"] = float(total_match.group(1)) if total_match else None
|
|
|
|
# Get remaining hours
|
|
hours_pattern = f'litellm_team_budget_remaining_hours_metric{{team="{team_id}",team_alias="[^"]*"}} ([0-9.]+)'
|
|
hours_match = re.search(hours_pattern, metrics_text)
|
|
metrics["remaining_hours"] = float(hours_match.group(1)) if hours_match else None
|
|
|
|
return metrics
|
|
|
|
|
|
async def create_test_key(session: aiohttp.ClientSession, team_id: str) -> str:
|
|
"""Generate a new key for the team and return it"""
|
|
url = "http://0.0.0.0:4000/key/generate"
|
|
headers = {
|
|
"Authorization": "Bearer sk-1234",
|
|
"Content-Type": "application/json",
|
|
}
|
|
data = {
|
|
"team_id": team_id,
|
|
}
|
|
|
|
async with session.post(url, headers=headers, json=data) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to generate key. Status: {response.status}"
|
|
key_info = await response.json()
|
|
return key_info["key"]
|
|
|
|
|
|
async def get_team_info(session: aiohttp.ClientSession, team_id: str) -> Dict[str, Any]:
|
|
"""Fetch team info and return the response"""
|
|
url = f"http://0.0.0.0:4000/team/info?team_id={team_id}"
|
|
headers = {
|
|
"Authorization": "Bearer sk-1234",
|
|
}
|
|
|
|
async with session.get(url, headers=headers) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to get team info. Status: {response.status}"
|
|
return await response.json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_budget_metrics():
|
|
"""
|
|
Test team budget tracking metrics:
|
|
1. Create a team with max_budget
|
|
2. Generate a key for the team
|
|
3. Make chat completion requests using OpenAI SDK with team's key
|
|
4. Verify budget decreases over time
|
|
5. Verify request costs are being tracked correctly
|
|
6. Verify prometheus metrics match /team/info spend data
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Setup test team
|
|
team_data = {
|
|
"team_alias": "budget_test_team",
|
|
"max_budget": 10,
|
|
"budget_duration": "7d",
|
|
}
|
|
team_id = await create_test_team(session, team_data)
|
|
print("team_id", team_id)
|
|
# Generate key for the team
|
|
team_key = await create_test_key(session, team_id)
|
|
|
|
# Initialize OpenAI client with team's key
|
|
client = AsyncOpenAI(base_url="http://0.0.0.0:4000", api_key=team_key)
|
|
|
|
# Make initial request and check budget
|
|
await client.chat.completions.create(
|
|
model="fake-openai-endpoint",
|
|
messages=[{"role": "user", "content": f"Hello {uuid.uuid4()}"}],
|
|
)
|
|
|
|
await asyncio.sleep(11) # Wait for metrics to update
|
|
|
|
# Get metrics after request
|
|
metrics_after_first = await get_prometheus_metrics(session)
|
|
print("metrics_after_first", metrics_after_first)
|
|
first_budget = extract_budget_metrics(metrics_after_first, team_id)
|
|
|
|
print(f"Budget after 1 request: {first_budget}")
|
|
assert (
|
|
first_budget["remaining"] < 10.0
|
|
), "remaining budget should be less than 10.0 after first request"
|
|
assert first_budget["total"] == 10.0, "Total budget metric is incorrect"
|
|
print("first_budget['remaining_hours']", first_budget["remaining_hours"])
|
|
# Budget should have positive remaining hours, up to 7 days
|
|
assert (
|
|
0 < first_budget["remaining_hours"] <= 168
|
|
), "Budget should have positive remaining hours, up to 7 days"
|
|
|
|
# Get team info and verify spend matches prometheus metrics
|
|
team_info = await get_team_info(session, team_id)
|
|
print("team_info", team_info)
|
|
_team_info_data = team_info["team_info"]
|
|
|
|
# Calculate spend from prometheus (total - remaining)
|
|
team_info_spend = float(_team_info_data["spend"])
|
|
team_info_max_budget = float(_team_info_data["max_budget"])
|
|
team_info_remaining_budget = team_info_max_budget - team_info_spend
|
|
print("\n\n\n###### Final budget metrics ######\n\n\n")
|
|
print("team_info_remaining_budget", team_info_remaining_budget)
|
|
print("prometheus_remaining_budget", first_budget["remaining"])
|
|
print(
|
|
"diff between team_info_remaining_budget and prometheus_remaining_budget",
|
|
team_info_remaining_budget - first_budget["remaining"],
|
|
)
|
|
|
|
# Verify spends match within a small delta (floating point comparison)
|
|
assert (
|
|
abs(team_info_remaining_budget - first_budget["remaining"]) <= 0.001
|
|
), f"Spend mismatch: Prometheus={team_info_remaining_budget}, Team Info={first_budget['remaining']}"
|
|
|
|
|
|
async def create_test_key_with_budget(
|
|
session: aiohttp.ClientSession, budget_data: Dict[str, Any]
|
|
) -> str:
|
|
"""Generate a new key with budget constraints and return it"""
|
|
url = "http://0.0.0.0:4000/key/generate"
|
|
headers = {
|
|
"Authorization": "Bearer sk-1234",
|
|
"Content-Type": "application/json",
|
|
}
|
|
print("budget_data", budget_data)
|
|
|
|
async with session.post(url, headers=headers, json=budget_data) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to generate key. Status: {response.status}"
|
|
key_info = await response.json()
|
|
return key_info["key"]
|
|
|
|
|
|
async def get_key_info(session: aiohttp.ClientSession, key: str) -> Dict[str, Any]:
|
|
"""Fetch key info and return the response"""
|
|
url = "http://0.0.0.0:4000/key/info"
|
|
headers = {
|
|
"Authorization": f"Bearer {key}",
|
|
}
|
|
|
|
async with session.get(url, headers=headers) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to get key info. Status: {response.status}"
|
|
return await response.json()
|
|
|
|
|
|
async def get_user_info(session: aiohttp.ClientSession, user_id: str) -> Dict[str, Any]:
|
|
"""Fetch user info and return the response"""
|
|
from urllib.parse import quote
|
|
|
|
# URL encode user_id to handle special characters
|
|
encoded_user_id = quote(user_id, safe="")
|
|
url = f"http://0.0.0.0:4000/user/info?user_id={encoded_user_id}"
|
|
headers = {
|
|
"Authorization": "Bearer sk-1234",
|
|
}
|
|
|
|
async with session.get(url, headers=headers) as response:
|
|
assert (
|
|
response.status == 200
|
|
), f"Failed to get user info. Status: {response.status}"
|
|
return await response.json()
|
|
|
|
|
|
def extract_key_budget_metrics(metrics_text: str, key_id: str) -> Dict[str, float]:
|
|
"""Extract budget-related metrics for a specific key"""
|
|
import re
|
|
|
|
metrics = {}
|
|
|
|
# Get remaining budget
|
|
remaining_pattern = f'litellm_remaining_api_key_budget_metric{{api_key_alias="[^"]*",hashed_api_key="{key_id}"}} ([0-9.]+)'
|
|
remaining_match = re.search(remaining_pattern, metrics_text)
|
|
metrics["remaining"] = float(remaining_match.group(1)) if remaining_match else None
|
|
|
|
# Get total budget
|
|
total_pattern = f'litellm_api_key_max_budget_metric{{api_key_alias="[^"]*",hashed_api_key="{key_id}"}} ([0-9.]+)'
|
|
total_match = re.search(total_pattern, metrics_text)
|
|
metrics["total"] = float(total_match.group(1)) if total_match else None
|
|
|
|
# Get remaining hours
|
|
hours_pattern = f'litellm_api_key_budget_remaining_hours_metric{{api_key_alias="[^"]*",hashed_api_key="{key_id}"}} ([0-9.]+)'
|
|
hours_match = re.search(hours_pattern, metrics_text)
|
|
metrics["remaining_hours"] = float(hours_match.group(1)) if hours_match else None
|
|
|
|
return metrics
|
|
|
|
|
|
def extract_user_budget_metrics(metrics_text: str, user_id: str) -> Dict[str, float]:
|
|
"""Extract budget-related metrics for a specific user"""
|
|
import re
|
|
|
|
metrics = {}
|
|
|
|
# Escape user_id for regex pattern matching
|
|
escaped_user_id = re.escape(user_id)
|
|
|
|
# Get remaining budget
|
|
remaining_pattern = (
|
|
f'litellm_remaining_user_budget_metric{{user="{escaped_user_id}"}} ([0-9.]+)'
|
|
)
|
|
remaining_match = re.search(remaining_pattern, metrics_text)
|
|
metrics["remaining"] = float(remaining_match.group(1)) if remaining_match else None
|
|
|
|
# Get total budget
|
|
total_pattern = (
|
|
f'litellm_user_max_budget_metric{{user="{escaped_user_id}"}} ([0-9.]+)'
|
|
)
|
|
total_match = re.search(total_pattern, metrics_text)
|
|
metrics["total"] = float(total_match.group(1)) if total_match else None
|
|
|
|
# Get remaining hours
|
|
hours_pattern = f'litellm_user_budget_remaining_hours_metric{{user="{escaped_user_id}"}} ([0-9.]+)'
|
|
hours_match = re.search(hours_pattern, metrics_text)
|
|
metrics["remaining_hours"] = float(hours_match.group(1)) if hours_match else None
|
|
|
|
return metrics
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_key_budget_metrics():
|
|
"""
|
|
Test key budget tracking metrics:
|
|
1. Create a key with max_budget
|
|
2. Make chat completion requests using OpenAI SDK with the key
|
|
3. Verify budget decreases over time
|
|
4. Verify request costs are being tracked correctly
|
|
5. Verify prometheus metrics match /key/info spend data
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
# Setup test key with unique alias
|
|
unique_alias = f"budget_test_key_{uuid.uuid4()}"
|
|
key_data = {
|
|
"key_alias": unique_alias,
|
|
"max_budget": 10,
|
|
"budget_duration": "7d",
|
|
"budget_reset_at": (
|
|
datetime.now(timezone.utc) + timedelta(days=7)
|
|
).isoformat(),
|
|
}
|
|
key = await create_test_key_with_budget(session, key_data)
|
|
|
|
# Extract key_id from the key info
|
|
key_info = await get_key_info(session, key)
|
|
print("key_info", key_info)
|
|
key_id = key_info["key"]
|
|
print("key_id", key_id)
|
|
|
|
# Initialize OpenAI client with the key
|
|
client = AsyncOpenAI(base_url="http://0.0.0.0:4000", api_key=key)
|
|
|
|
# Make initial request and check budget
|
|
await client.chat.completions.create(
|
|
model="fake-openai-endpoint",
|
|
messages=[{"role": "user", "content": f"Hello {uuid.uuid4()}"}],
|
|
)
|
|
|
|
await asyncio.sleep(11) # Wait for metrics to update
|
|
|
|
# Get metrics after request
|
|
metrics_after_first = await get_prometheus_metrics(session)
|
|
print("metrics_after_first request", metrics_after_first)
|
|
first_budget = extract_key_budget_metrics(metrics_after_first, key_id)
|
|
|
|
print(f"Budget after 1 request: {first_budget}")
|
|
assert (
|
|
first_budget["remaining"] < 10.0
|
|
), "remaining budget should be less than 10.0 after first request"
|
|
assert first_budget["total"] == 10.0, "Total budget metric is incorrect"
|
|
print("first_budget['remaining_hours']", first_budget["remaining_hours"])
|
|
# The budget reset time is now standardized - for "7d" it resets on Monday at midnight
|
|
# So we'll check if it's within a reasonable range (0-7 days depending on current day of week)
|
|
assert (
|
|
0 <= first_budget["remaining_hours"] <= 168
|
|
), "Budget remaining hours should be within a reasonable range (0-7 days depending on day of week)"
|
|
|
|
# Get key info and verify spend matches prometheus metrics
|
|
key_info = await get_key_info(session, key)
|
|
print("key_info", key_info)
|
|
_key_info_data = key_info["info"]
|
|
|
|
# Calculate spend from prometheus (total - remaining)
|
|
key_info_spend = float(_key_info_data["spend"])
|
|
key_info_max_budget = float(_key_info_data["max_budget"])
|
|
key_info_remaining_budget = key_info_max_budget - key_info_spend
|
|
print("\n\n\n###### Final budget metrics ######\n\n\n")
|
|
print("key_info_remaining_budget", key_info_remaining_budget)
|
|
print("prometheus_remaining_budget", first_budget["remaining"])
|
|
print(
|
|
"diff between key_info_remaining_budget and prometheus_remaining_budget",
|
|
key_info_remaining_budget - first_budget["remaining"],
|
|
)
|
|
|
|
# Verify spends match within a small delta (floating point comparison)
|
|
assert (
|
|
abs(key_info_remaining_budget - first_budget["remaining"]) <= 0.001
|
|
), f"Spend mismatch: Prometheus={key_info_remaining_budget}, Key Info={first_budget['remaining']}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_budget_metrics():
|
|
"""
|
|
Test user budget tracking metrics:
|
|
1. Create a user with max_budget
|
|
2. Make chat completion requests using OpenAI SDK with the user's key
|
|
3. Verify budget decreases over time
|
|
4. Verify request costs are being tracked correctly
|
|
5. Verify prometheus metrics match /user/info spend data
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
# Setup test user with unique user_id
|
|
unique_user_id = f"budget_test_user_{uuid.uuid4()}"
|
|
user_data = {
|
|
"user_id": unique_user_id,
|
|
"max_budget": 10,
|
|
"budget_duration": "7d",
|
|
"budget_reset_at": (
|
|
datetime.now(timezone.utc) + timedelta(days=7)
|
|
).isoformat(),
|
|
}
|
|
user_info = await create_test_user(session, user_data)
|
|
print("user_info", user_info)
|
|
user_id = user_info["user_id"]
|
|
print("user_id", user_id)
|
|
# Get the key that was created with the user
|
|
key = user_info["key"]
|
|
|
|
# Initialize OpenAI client with the user's key
|
|
client = AsyncOpenAI(base_url="http://0.0.0.0:4000", api_key=key)
|
|
|
|
# Make initial request and check budget
|
|
await client.chat.completions.create(
|
|
model="fake-openai-endpoint",
|
|
messages=[{"role": "user", "content": f"Hello {uuid.uuid4()}"}],
|
|
)
|
|
|
|
await asyncio.sleep(11) # Wait for metrics to update
|
|
|
|
# Get metrics after request
|
|
metrics_after_first = await get_prometheus_metrics(session)
|
|
print("metrics_after_first request", metrics_after_first)
|
|
first_budget = extract_user_budget_metrics(metrics_after_first, user_id)
|
|
|
|
print(f"Budget after 1 request: {first_budget}")
|
|
assert (
|
|
first_budget["remaining"] is not None
|
|
), "remaining budget metric should be present"
|
|
assert (
|
|
first_budget["total"] is not None
|
|
), "total budget metric should be present"
|
|
assert (
|
|
first_budget["remaining"] < 10.0
|
|
), "remaining budget should be less than 10.0 after first request"
|
|
assert first_budget["total"] == 10.0, "Total budget metric is incorrect"
|
|
print("first_budget['remaining_hours']", first_budget["remaining_hours"])
|
|
# The budget reset time is now standardized - for "7d" it resets on Monday at midnight
|
|
# So we'll check if it's within a reasonable range (0-7 days depending on current day of week)
|
|
assert (
|
|
first_budget["remaining_hours"] is not None
|
|
), "remaining hours metric should be present"
|
|
assert (
|
|
0 <= first_budget["remaining_hours"] <= 168
|
|
), "Budget remaining hours should be within a reasonable range (0-7 days depending on day of week)"
|
|
|
|
# Get user info and verify spend matches prometheus metrics
|
|
user_info_response = await get_user_info(session, user_id)
|
|
print("user_info_response", user_info_response)
|
|
_user_info_data = user_info_response["user_info"]
|
|
|
|
# Calculate spend from prometheus (total - remaining)
|
|
user_info_spend = float(_user_info_data["spend"])
|
|
user_info_max_budget = float(_user_info_data["max_budget"])
|
|
user_info_remaining_budget = user_info_max_budget - user_info_spend
|
|
print("\n\n\n###### Final budget metrics ######\n\n\n")
|
|
print("user_info_remaining_budget", user_info_remaining_budget)
|
|
print("prometheus_remaining_budget", first_budget["remaining"])
|
|
print(
|
|
"diff between user_info_remaining_budget and prometheus_remaining_budget",
|
|
user_info_remaining_budget - first_budget["remaining"],
|
|
)
|
|
|
|
# Verify spends match within a small delta (floating point comparison)
|
|
assert (
|
|
abs(user_info_remaining_budget - first_budget["remaining"]) <= 0.001
|
|
), f"Spend mismatch: Prometheus={user_info_remaining_budget}, User Info={first_budget['remaining']}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_email_metrics():
|
|
"""
|
|
Test user email tracking metrics:
|
|
1. Create a user with user_email
|
|
2. Make chat completion requests using OpenAI SDK with the user's email
|
|
3. Verify user email is being tracked correctly in `litellm_user_email_metric`
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create a user with user_email
|
|
user_email = f"test-{uuid.uuid4()}@example.com"
|
|
user_data = {
|
|
"user_email": user_email,
|
|
}
|
|
user_info = await create_test_user(session, user_data)
|
|
key = user_info["key"]
|
|
|
|
# Initialize OpenAI client with the user's email
|
|
client = AsyncOpenAI(base_url="http://0.0.0.0:4000", api_key=key)
|
|
|
|
# Make initial request and check budget
|
|
await client.chat.completions.create(
|
|
model="fake-openai-endpoint",
|
|
messages=[{"role": "user", "content": f"Hello {uuid.uuid4()}"}],
|
|
)
|
|
|
|
await asyncio.sleep(11) # Wait for metrics to update
|
|
|
|
# Get metrics after request
|
|
metrics_after_first = await get_prometheus_metrics(session)
|
|
print("metrics_after_first request", metrics_after_first)
|
|
assert (
|
|
user_email in metrics_after_first
|
|
), "user_email should be tracked correctly"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_email_in_all_required_metrics():
|
|
"""
|
|
Test that user_email label is present in all the metrics that were requested to have it:
|
|
- litellm_proxy_total_requests_metric_total
|
|
- litellm_proxy_failed_requests_metric_total
|
|
- litellm_input_tokens_metric_total
|
|
- litellm_output_tokens_metric_total
|
|
- litellm_requests_metric_total
|
|
- litellm_spend_metric_total
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
# Create a user with user_email
|
|
user_email = f"test-metrics-{uuid.uuid4()}@example.com"
|
|
user_data = {
|
|
"user_email": user_email,
|
|
}
|
|
user_info = await create_test_user(session, user_data)
|
|
key = user_info["key"]
|
|
|
|
# Initialize OpenAI client with the user's email
|
|
client = AsyncOpenAI(base_url="http://0.0.0.0:4000", api_key=key)
|
|
|
|
# Make successful request to generate metrics
|
|
await client.chat.completions.create(
|
|
model="fake-openai-endpoint",
|
|
messages=[{"role": "user", "content": f"Hello {uuid.uuid4()}"}],
|
|
)
|
|
|
|
await asyncio.sleep(11) # Wait for metrics to update
|
|
|
|
# Get metrics after request
|
|
metrics_text = await get_prometheus_metrics(session)
|
|
print("Testing user_email in all required metrics")
|
|
|
|
# Check that user_email appears in all the required metrics
|
|
required_metrics_with_user_email = [
|
|
# "litellm_proxy_total_requests_metric_total",
|
|
# "litellm_input_tokens_metric_total",
|
|
# "litellm_output_tokens_metric_total",
|
|
# "litellm_requests_metric_total",
|
|
"litellm_spend_metric_total",
|
|
]
|
|
|
|
import re
|
|
|
|
for metric_name in required_metrics_with_user_email:
|
|
# Check that the metric exists and contains user_email label
|
|
# Look for the metric with user_email in its labels
|
|
pattern = (
|
|
rf'{metric_name}{{[^}}]*user_email="{re.escape(user_email)}"[^}}]*}}'
|
|
)
|
|
matches = re.findall(pattern, metrics_text)
|
|
assert (
|
|
len(matches) > 0
|
|
), f"Metric {metric_name} should contain user_email={user_email} but was not found in metrics"
|
|
|
|
# Also test failure metric by making a bad request
|
|
try:
|
|
await client.chat.completions.create(
|
|
model="fake-azure-endpoint", # This should fail
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
)
|
|
except Exception:
|
|
pass # Expected to fail
|
|
|
|
await asyncio.sleep(11) # Wait for metrics to update
|
|
|
|
# Get updated metrics
|
|
metrics_text = await get_prometheus_metrics(session)
|
|
|
|
# Check that failure metric also contains user_email
|
|
failure_pattern = rf'litellm_proxy_failed_requests_metric_total{{[^}}]*user_email="{re.escape(user_email)}"[^}}]*}}'
|
|
failure_matches = re.findall(failure_pattern, metrics_text)
|
|
assert (
|
|
len(failure_matches) > 0
|
|
), f"litellm_proxy_failed_requests_metric_total should contain user_email={user_email}"
|