Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions helm/litellm-helm/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,28 @@ app.kubernetes.io/name: {{ include "litellm.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Labels for the migrations Job's pods.

Deliberately NOT "litellm.labels". That helper embeds "litellm.selectorLabels",
which is the exact selector of the litellm Service, the metrics Service, the
ServiceMonitor and the PodDisruptionBudget. A migrations pod carrying those
labels is picked up by all four even though its container exposes no ports.
Overriding app.kubernetes.io/name breaks the match while keeping the pod
identifiable, and the component label mirrors the litellm chart's own
migrations Job.
*/}}
{{- define "litellm.migrationsLabels" -}}
helm.sh/chart: {{ include "litellm.chart" . }}
app.kubernetes.io/name: {{ printf "%s-migrations" (include "litellm.name" .) | trunc 63 | trimSuffix "-" }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: migrations
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Enterprise billable-request metering. The client certificate identifies the
deployment to LiteLLM's collector, so it is mounted read-only from an existing
Expand Down
14 changes: 10 additions & 4 deletions helm/litellm-helm/templates/migrations-job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ metadata:
spec:
template:
metadata:
{{- /* The Job's pod selector is generated by the controller rather than
declared, so podLabels may override a chart label here. Merge
instead of appending so an override replaces the key rather than
rendering it twice. app.kubernetes.io/name is then forced back:
letting podLabels win that one key puts these pods into the
Service and PodDisruptionBudget selectors again. */}}
{{- $chartLabels := fromYaml (include "litellm.migrationsLabels" .) }}
{{- $podLabels := merge (deepCopy (default dict .Values.podLabels)) $chartLabels }}
{{- $_ := set $podLabels "app.kubernetes.io/name" (get $chartLabels "app.kubernetes.io/name") }}
labels:
{{- include "litellm.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- toYaml $podLabels | nindent 8 }}
annotations:
{{- with .Values.migrationJob.annotations }}
{{- toYaml . | nindent 8 }}
Expand Down
67 changes: 67 additions & 0 deletions helm/litellm-helm/tests/migrations-job_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,70 @@ tests:
asserts:
- notExists:
path: spec.activeDeadlineSeconds

- it: should not label its pods with the Service selector labels
template: migrations-job.yaml
set:
migrationJob:
enabled: true
asserts:
# app.kubernetes.io/name must differ from "litellm.selectorLabels", or the
# litellm Service, metrics Service, ServiceMonitor and PDB all select this
# pod even though its container exposes no ports.
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/name"]
value: litellm-migrations
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: migrations
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/instance"]
value: RELEASE-NAME

- it: should keep podLabels on its pods and let them override chart labels
template: migrations-job.yaml
set:
migrationJob:
enabled: true
podLabels:
team: platform
app.kubernetes.io/component: custom-component
asserts:
- equal:
path: spec.template.metadata.labels.team
value: platform
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/component"]
value: custom-component
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/name"]
value: litellm-migrations

- it: should keep the migrations name label within the 63 character limit
template: migrations-job.yaml
set:
migrationJob:
enabled: true
nameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
asserts:
# "litellm.name" already truncates to 63, so appending "-migrations"
# afterwards would produce a 71 character value and Kubernetes rejects
# any label longer than 63, failing the install.
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/name"]
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-mi

- it: should not let podLabels put its pods back into the Service selector
template: migrations-job.yaml
set:
migrationJob:
enabled: true
podLabels:
app.kubernetes.io/name: litellm
asserts:
# podLabels is the same global map the deployment pods use, so an override
# of this key would otherwise restore the very selector match this
# template exists to break.
- equal:
path: spec.template.metadata.labels["app.kubernetes.io/name"]
value: litellm-migrations
8 changes: 6 additions & 2 deletions litellm/proxy/db/health_check_latest.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,14 @@ def _assume_utc(cls, value: datetime) -> datetime:
_ROWS_ADAPTER: Final = TypeAdapter(tuple[LatestHealthCheckRow, ...])


async def query_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]:
rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL)
return _ROWS_ADAPTER.validate_python(rows)


async def fetch_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]:
try:
rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL)
return _ROWS_ADAPTER.validate_python(rows)
return await query_latest_health_checks(prisma_client)
except Exception as query_err: # noqa: BLE001 # health decorates other reads; a driver error must not fail them
verbose_proxy_logger.error("Error getting all latest health checks: %s", query_err)
return ()
Expand Down
8 changes: 7 additions & 1 deletion litellm/proxy/dd_span_tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ def tag_request(
- ``litellm.key_alias`` — human-readable alias for the API key
- ``litellm.key_hash`` — hashed API key (safe to log; never the raw secret)
- ``litellm.requested_model``— model name as sent by the client
- ``litellm.user_email`` — email of the authenticated user

Use cases:
- Trace all requests from a specific user/key: filter by ``litellm.key_alias`` or
``litellm.key_hash``.
- Trace all requests for a specific model: filter by ``litellm.requested_model``.
- Trace all requests from a specific person under JWT auth, where there is no key_alias:
filter by ``@litellm.user_email:"user@example.com"``. The value comes from the JWT claim
mapped by ``litellm_jwtauth.user_email_jwt_field``, or from the user row for virtual keys.

Note: key_alias / key_hash are not available for unauthenticated (e.g. 401) requests.
"""
Expand All @@ -51,8 +55,10 @@ def tag_request(
set_active_span_tag("litellm.key_hash", str(user_api_key_dict.token))
if requested_model:
set_active_span_tag("litellm.requested_model", str(requested_model))
if user_api_key_dict.user_email:
set_active_span_tag("litellm.user_email", str(user_api_key_dict.user_email))
except Exception:
verbose_proxy_logger.debug(
"Failed to tag active ddtrace span with key/model tags",
"Failed to tag active ddtrace span with key/model/user tags",
exc_info=True,
)
7 changes: 5 additions & 2 deletions litellm/proxy/health_endpoints/_health_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@
from litellm.proxy.auth.model_checks import get_key_models
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.db.health_check_latest import LatestHealthCheckRow
from litellm.proxy.db.health_check_latest import (
LatestHealthCheckRow,
query_latest_health_checks,
)
from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers
from litellm.proxy.health_check import (
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS,
Expand Down Expand Up @@ -876,7 +879,7 @@ async def _save_background_health_checks_to_db(
)

# Step 3: Get latest health checks for all models in one query to compare status
latest_checks: Final = await prisma_client.get_all_latest_health_checks()
latest_checks: Final = await query_latest_health_checks(prisma_client)
latest_checks_map: Final = {}
for check in latest_checks:
# Use model_id as primary key, fallback to model_name
Expand Down
5 changes: 5 additions & 0 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -10683,6 +10683,10 @@ def _set_model_group_info(self, model_group: str, user_facing_model_group_name:
model_list: Final = self.get_model_list(model_name=model_group)
if model_list is None:
return None
model_group_description: Final[str | None] = next(
(d for d in (m.get("model_info", {}).get("description") for m in model_list) if isinstance(d, str)),
None,
)
for model in model_list:
is_match = False
if (
Expand Down Expand Up @@ -10787,6 +10791,7 @@ def _set_model_group_info(self, model_group: str, user_facing_model_group_name:
**{
"model_group": user_facing_model_group_name,
"providers": [llm_provider],
"description": model_group_description,
**model_info,
"supported_reasoning_efforts": None,
}
Expand Down
1 change: 1 addition & 0 deletions litellm/types/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@ class ModelGroupInfo(BaseModel):
supported_reasoning_efforts: tuple[str, ...] | None = Field(default=None)
supported_openai_params: list[str] | None = Field(default=[])
configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None
description: str | None = None

def __init__(self, **data) -> None:
for field_name, field_type in _resolved_annotations(self.__class__).items():
Expand Down
10 changes: 10 additions & 0 deletions tests/test_litellm/proxy/db/test_health_check_latest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
LATEST_HEALTH_CHECKS_SQL,
fetch_latest_health_checks,
fetch_latest_health_checks_for_models,
query_latest_health_checks,
)


Expand Down Expand Up @@ -83,6 +84,15 @@ async def test_fetch_all_degrades_to_no_rows_when_the_query_fails():
assert await fetch_latest_health_checks(prisma) == ()


@pytest.mark.asyncio
async def test_query_all_raises_when_the_query_fails_instead_of_reading_as_an_empty_table():
"""The background save decides what to write from this read; a failure has to be told apart from no rows."""
prisma = _prisma([])
prisma.db.query_raw.side_effect = RuntimeError("db down")
with pytest.raises(RuntimeError, match="db down"):
await query_latest_health_checks(prisma)


@pytest.mark.asyncio
async def test_fetch_all_degrades_to_no_rows_for_a_malformed_row():
assert await fetch_latest_health_checks(_prisma([{"unexpected": "shape"}])) == ()
Expand Down
27 changes: 26 additions & 1 deletion tests/test_litellm/proxy/test_common_request_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3177,12 +3177,13 @@ def test_streaming_overhead_header_in_custom_headers_from_stream_hidden_params(
class TestDDSpanTaggerTagRequest:
"""Tests for DDSpanTagger.tag_request - key/model DD span tagging."""

def _make_user_api_key_dict(self, key_alias=None, token=None):
def _make_user_api_key_dict(self, key_alias=None, token=None, user_email=None):
from litellm.proxy._types import UserAPIKeyAuth

d = UserAPIKeyAuth()
d.key_alias = key_alias
d.token = token
d.user_email = user_email
return d

def test_tags_key_alias_and_model(self):
Expand Down Expand Up @@ -3223,6 +3224,30 @@ def test_only_model_tagged_when_no_key_info(self):

mock_set_tag.assert_called_once_with("litellm.requested_model", "claude-3-5-sonnet")

def test_tags_user_email(self):
"""user_email is tagged so JWT-authenticated requests are traceable per person."""
user_key = self._make_user_api_key_dict(user_email="user@example.com")

with patch("litellm.proxy.dd_span_tagger.set_active_span_tag") as mock_set_tag:
DDSpanTagger.tag_request(
user_api_key_dict=user_key,
requested_model=None,
)

mock_set_tag.assert_called_once_with("litellm.user_email", "user@example.com")

def test_no_user_email_tag_when_absent(self):
"""No user email tag when the authenticated identity has no email."""
user_key = self._make_user_api_key_dict(key_alias="my-prod-key", user_email=None)

with patch("litellm.proxy.dd_span_tagger.set_active_span_tag") as mock_set_tag:
DDSpanTagger.tag_request(
user_api_key_dict=user_key,
requested_model="gpt-4o",
)

assert all(call.args[0] != "litellm.user_email" for call in mock_set_tag.call_args_list)


class TestHasAttributeErrorInChain:
"""Tests for _has_attribute_error_in_chain helper."""
Expand Down
27 changes: 22 additions & 5 deletions tests/test_litellm/proxy/test_health_check_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ async def test_save_background_health_checks_to_db():
"""Test the main background health check save function"""
mock_prisma = MagicMock()
mock_prisma.save_health_check_result = AsyncMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
mock_prisma.db.query_raw = AsyncMock(return_value=[])

model_list = [
{
Expand All @@ -398,9 +398,9 @@ async def test_save_background_health_checks_to_db():
"background_health_check",
)

# Should call get_all_latest_health_checks and save_health_check_result, and report completion
# Should read the latest rows and save_health_check_result, and report completion
assert persisted is True
mock_prisma.get_all_latest_health_checks.assert_called_once()
mock_prisma.db.query_raw.assert_awaited_once()
mock_prisma.save_health_check_result.assert_called_once()

call_kwargs = mock_prisma.save_health_check_result.call_args[1]
Expand Down Expand Up @@ -493,7 +493,7 @@ def _one_model_setup():
@pytest.mark.asyncio
async def test_save_background_health_checks_to_db_returns_false_when_a_write_fails():
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
mock_prisma.db.query_raw = AsyncMock(return_value=[])
mock_prisma.save_health_check_result = AsyncMock(return_value=None)
model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup()

Expand All @@ -504,6 +504,23 @@ async def test_save_background_health_checks_to_db_returns_false_when_a_write_fa
assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 1)


@pytest.mark.asyncio
async def test_save_background_health_checks_to_db_writes_nothing_when_the_latest_row_read_fails(mock_prisma):
"""
A failed dedup read must not read as an empty table. Treated that way, every model was written on every
cycle by every pod while the read kept failing, which is what filled the table in production.
"""
mock_prisma.db.query_raw = AsyncMock(side_effect=RuntimeError("db down"))
mock_prisma.save_health_check_result = AsyncMock(return_value={"id": "row"})
model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup()

persisted = await _save_background_health_checks_to_db(
mock_prisma, model_list, healthy_endpoints, unhealthy_endpoints, 1234567890.0, "background_health_check"
)

assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 0)


@pytest.mark.asyncio
async def test_save_background_health_checks_to_db_no_prisma():
"""Test graceful handling when no prisma client"""
Expand All @@ -515,7 +532,7 @@ async def test_save_background_health_checks_to_db_no_prisma():
async def test_save_background_health_checks_to_db_exception_handling():
"""Test exception handling in background health check save"""
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(side_effect=Exception("DB Error"))
mock_prisma.db.query_raw = AsyncMock(side_effect=Exception("DB Error"))

model_list = [
{
Expand Down
25 changes: 25 additions & 0 deletions tests/test_litellm/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,31 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost():
assert result.output_cost_per_token is None


def test_model_group_info_description_from_model_info():
"""
model_info.description set in the config should surface on ModelGroupInfo,
including when only a later deployment in the group carries it.
"""
router = litellm.Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4", "api_key": "fake"},
"model_info": {},
},
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4", "api_key": "fake2"},
"model_info": {"description": "State-of-the-art language model."},
},
]
)

result = router.get_model_group_info("gpt-4")
assert result is not None
assert result.description == "State-of-the-art language model."


@pytest.mark.parametrize(
"value,expected",
[
Expand Down
6 changes: 6 additions & 0 deletions ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,12 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
))}
</div>
</div>
{selectedModel.description && (
<div className="col-span-2">
<p className="font-medium">Description:</p>
<p className="mt-1">{selectedModel.description}</p>
</div>
)}
</div>
</div>

Expand Down
Loading
Loading