Skip to content
Merged
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
29 changes: 29 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2397,6 +2397,17 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"`statement_cache_size`). Keys here override any default LiteLLM sets."
),
)
database_disable_prepared_statements: Optional[bool] = Field(
None,
description=(
"Disable server-side prepared statements by setting Prisma's "
"`pgbouncer=true` URL param. Use this for pgbouncer transaction-pooling "
"deployments, or to prevent the 'cached plan must not change result "
"type' error that pooled connections hit during rolling schema "
"migrations. An explicit `pgbouncer` in `database_extra_connection_params` "
"takes precedence."
),
)
database_type: Optional[Literal["dynamo_db"]] = Field(
None, description="to use dynamodb instead of postgres db"
)
Expand Down Expand Up @@ -2533,6 +2544,24 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="List of MCP server fields that must be filled in for a submission to pass standards checks (e.g. ['description', 'source_url', 'alias']).",
)
disable_budget_reservation: Optional[bool] = Field(
None,
description=(
"If True, disables the optimistic per-request budget reservation "
"introduced in v1.84.0. "
"WARNING: This weakens hard budget enforcement. Without the reservation, "
"a burst of concurrent requests from a single key can each pass the "
"read-time spend check before any of them is charged, allowing a "
"configured budget to be exceeded under high concurrency. "
"Budgets are still evaluated on every request at read time, so "
"an already-exhausted budget is still rejected. "
"Enable only if your deployment is experiencing phantom "
"BudgetExceededError responses caused by leaked reservations "
"(see GitHub issue #27639). "
"A proxy-level WARNING is logged on every request while this flag "
"is active as a reminder that hard enforcement is relaxed."
),
)


class ConfigYAML(LiteLLMPydanticObjectBase):
Expand Down
10 changes: 10 additions & 0 deletions litellm/proxy/auth/auth_exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ async def _handle_authentication_error(
)
elif isinstance(e, ProxyException):
raise e
if PrismaDBExceptionHandler.is_database_service_unavailable_error(e):
raise ProxyException(
message=(
"Service Unavailable, the authentication database is "
"temporarily unreachable. Please retry shortly."
),
type=ProxyErrorTypes.no_db_connection,
param="None",
code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
raise ProxyException(
message="Authentication Error, " + str(e),
type=ProxyErrorTypes.auth_error,
Expand Down
12 changes: 12 additions & 0 deletions litellm/proxy/auth/user_api_key_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2108,6 +2108,7 @@ async def _run_centralized_common_checks( # noqa: PLR0915
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
skip_budget_checks=skip_budget_checks,
general_settings=general_settings,
)


Expand All @@ -2128,12 +2129,23 @@ async def _reserve_budget_after_common_checks(
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
skip_budget_checks: bool,
general_settings: dict,
end_user_id: Optional[str] = None,
end_user_object: Optional[LiteLLM_EndUserTable] = None,
) -> None:
user_api_key_auth_obj.budget_reservation = None
if skip_budget_checks:
return
if general_settings.get("disable_budget_reservation") is True:
verbose_proxy_logger.warning(
"disable_budget_reservation is enabled: skipping optimistic budget "
"reservation. Budget enforcement is read-time only — concurrent "
"requests can each pass the spend check before their cost is recorded, "
"so a configured budget may be briefly exceeded under high concurrency. "
"Set disable_budget_reservation to False or remove it to restore "
"hard per-request budget enforcement."
)
return

from litellm.proxy.spend_tracking.budget_reservation import (
reserve_budget_for_request,
Expand Down
86 changes: 86 additions & 0 deletions litellm/proxy/db/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,92 @@ def is_database_transport_error(e: Exception) -> bool:
return True
return False

@staticmethod
def is_prisma_engine_internal_error(e: Exception) -> bool:
"""True iff ``e`` is a non-``PrismaError`` exception raised from inside
prisma-client-py's query-engine layer.

During the instant a DB connection is torn down, the query engine can
return a malformed error payload (``user_facing_error.meta`` is
``null``). prisma-client-py's ``handle_response_errors`` then crashes
with ``AttributeError: 'NoneType' object has no attribute 'get'``
before it can raise the proper P1001 "can't reach database server"
error. That AttributeError carries no connection keyword, so it can't
be matched by message; identify it by its ``prisma.engine`` origin
instead.

Recognized ``PrismaError`` subclasses are excluded: connectivity ones
are already classified by type/keyword above, and data-layer ones
(the DB IS reachable) must stay 401.
"""
import prisma

if isinstance(e, prisma.errors.PrismaError):
return False
tb = getattr(e, "__traceback__", None)
while tb is not None:
if tb.tb_frame.f_globals.get("__name__", "").startswith("prisma.engine"):
return True
tb = tb.tb_next
Comment on lines +113 to +138

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Traceback-frame inspection couples detection to prisma-client-py's internal module structure

is_prisma_engine_internal_error walks every frame in e.__traceback__ looking for a prisma.engine.* module name. This works for the specific prisma-client-py version shipped here, but if a future prisma release reorganises its engine modules (e.g., renames to prisma._engine), the check silently stops matching and the malformed-payload AttributeError falls through to 401 again — exactly the bug this was added to prevent. Worth noting the prisma version assumption in the docstring, and adding a version pin or a canary test against the real prisma.engine.utils.handle_response_errors path to detect drift early.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

return False

@staticmethod
def is_database_service_unavailable_error(e: Exception) -> bool:
"""True iff the exception means the database could not answer at the
infrastructure level (connection refused, socket/interface failure,
timeout) rather than a genuine auth failure (key not found) or a
data-layer error (the DB IS reachable and rejected the data).

Auth must answer 401 only for a key the DB confirms is invalid. When
Comment on lines +113 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Traceback-frame inspection is sensitive to prisma package layout

is_prisma_engine_internal_error identifies the malformed-payload AttributeError by checking whether any frame in e.__traceback__ comes from a module whose __name__ starts with "prisma.engine". This works correctly for the known prisma-client-py layout, and the test pins the exact crash path via prisma.engine.utils.handle_response_errors. However, if prisma-client-py ever renames or splits that sub-package (e.g. prisma._engine), the check silently returns False and the first-request-of-an-outage edge case regresses to 401 with no visible error. A comment noting the version this was verified against would help future maintainers know when to re-validate.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

the DB itself is unreachable, the request has to surface as 503 so
callers retry instead of treating valid keys as invalid during an
outage.

Note: prisma-client-py mislabels the P1001 "can't reach database
server" connectivity failure as a ``DataError`` (a data-layer type),
so a type-only check misses real outages. ``is_database_transport_error``
keyword-matches the connection message and catches that masquerade,
while genuine data errors (no connection keyword) correctly stay 401.

The Postgres "cached plan must not change result type" error is matched
Comment on lines +141 to +159

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Broad OSError catch may misclassify unrelated errors as 503

isinstance(e, (OSError, asyncio.TimeoutError)) catches every Python OSError and its subclasses (ConnectionError, FileNotFoundError, PermissionError, etc.) plus any asyncio.TimeoutError. In the auth critical path this is almost always a DB-layer network failure, but a stray filesystem OSError or an unrelated coroutine timeout would also be reclassified from 401 to 503. Callers that retry on 503 would spin indefinitely against a genuinely bad request. Narrowing to ConnectionError, TimeoutError, and asyncio.TimeoutError would cover the intended transport failures without the full OSError hierarchy.

here, not in ``is_database_transport_error``: it is a transient stale-DB-
state condition (not an invalid key), but the connection is healthy so it
must not trigger a reconnect.

A non-``PrismaError`` raised from inside the prisma query engine (e.g.
the ``AttributeError`` from ``handle_response_errors`` when the engine
returns a malformed error payload mid-tear-down) is also treated as
unavailable; see ``is_prisma_engine_internal_error``.
"""
import asyncio

if PrismaDBExceptionHandler.is_database_connection_error(e):
return True
if PrismaDBExceptionHandler.is_database_transport_error(e):
return True
if PrismaDBExceptionHandler.is_prisma_engine_internal_error(e):
return True
if "cached plan must not change result type" in str(e).lower():
return True

# OSError already covers ConnectionError and (Py3.3+) TimeoutError.
# asyncio.TimeoutError is a distinct class before Py3.11.
if isinstance(e, (OSError, asyncio.TimeoutError)):
return True

try:
import asyncpg
except ImportError:
return False

return isinstance(
e,
(
asyncpg.exceptions.PostgresConnectionError,
asyncpg.exceptions.InterfaceError,
),
)

Comment thread
greptile-apps[bot] marked this conversation as resolved.
@staticmethod
def handle_db_exception(e: Exception):
"""
Expand Down
42 changes: 39 additions & 3 deletions litellm/proxy/management_endpoints/model_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,9 +490,45 @@ def _get_public_model_name(
patch_data: updateDeployment,
db_model: Deployment,
) -> str:
"""Determine the public model name from patch or existing model."""
if patch_data.model_name:
return patch_data.model_name
"""Determine the public model name from patch or existing model.

The top-level ``model_name`` is the rename channel. For team-scoped rows
the DB ``model_name`` column holds an internal routing key
(``model_name_{team_id}_{uuid}``), and ``/model/info`` historically leaked
it into the dashboard edit form, so a non-rename save (e.g. a TPM tweak)
would PATCH the internal name and the update path would treat it as a
rename -- overwriting ``team_public_model_name`` and rewriting the team ACL
(see issue #28382).

Guard against that by ignoring an incoming ``model_name`` that matches the
internal shape, or is a no-op against the current DB column. Anything else
is a genuine rename and wins. We deliberately do NOT read
``patch_data.model_info.team_public_model_name``: the dashboard passes the
existing ``model_info`` blob through untouched on a rename, so honoring it
would return the OLD public name and silently drop the rename.

Precedence (highest first):
1. patch_data.model_name -- a genuine rename: not internal-shape and not a
no-op against db_model.model_name.
2. db_model.model_info.team_public_model_name -- existing public name.
3. db_model.model_name -- last-resort fallback for legacy rows.
"""
team_id = (patch_data.model_info.team_id if patch_data.model_info else None) or (
db_model.model_info.team_id if db_model.model_info else None
)

def _is_internal_shape(name: Optional[str]) -> bool:
if team_id is None or not name:
return False
return name.startswith(f"model_name_{team_id}_")

incoming = patch_data.model_name
if (
incoming
and not _is_internal_shape(incoming)
and incoming != db_model.model_name
):
return incoming

if db_model.model_info and db_model.model_info.team_public_model_name:
return db_model.model_info.team_public_model_name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,42 @@ def _get_user_from_metadata(
return get_end_user_id_from_request_body(request_body)
return None

@staticmethod
def _resolve_costing_model(model: str, logging_obj: LiteLLMLoggingObj) -> str:
if model and model != "unknown":
return model
litellm_params = (getattr(logging_obj, "model_call_details", {}) or {}).get(
"litellm_params", {}
) or {}
deployment_model = litellm_params.get("model")
if deployment_model and deployment_model != "unknown":
return deployment_model
model_group = (litellm_params.get("metadata", {}) or {}).get("model_group")
if model_group:
return model_group.removeprefix("passthrough/")
return model

@staticmethod
def _extract_model_from_anthropic_chunks(
all_chunks: Sequence[Union[str, bytes]],
) -> Optional[str]:
for raw in all_chunks:
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Bytes are decoded with UTF-8 and no error handler. An Anthropic-compatible upstream that emits a non-UTF-8 byte sequence in a chunk (e.g., a malformed binary ping frame) would raise UnicodeDecodeError, which is not caught by the json.JSONDecodeError handler downstream and would propagate out of _handle_logging_anthropic_collected_chunks, breaking spend logging for the whole request. Adding errors="replace" keeps parsing going without swallowing the frame silently.

Suggested change
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw

for line in text.splitlines():
if not line.startswith("data:"):
continue
try:
data = json.loads(line[len("data:") :].strip())
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(data, dict):
continue
if data.get("type") == "message_start":
model = (data.get("message") or {}).get("model")
if model:
return model
return None

@staticmethod
def _create_anthropic_response_logging_payload(
litellm_model_response: Union[ModelResponse, TextCompletionResponse],
Expand Down Expand Up @@ -127,6 +163,10 @@ def _create_anthropic_response_logging_payload(
"custom_llm_provider"
)

model = AnthropicPassthroughLoggingHandler._resolve_costing_model(
model, logging_obj
)

# Prepend custom_llm_provider to model if not already present
model_for_cost = model
if custom_llm_provider and not model.startswith(f"{custom_llm_provider}/"):
Expand Down Expand Up @@ -213,6 +253,15 @@ def _handle_logging_anthropic_collected_chunks(
):
model = cast(str, litellm_logging_obj.model_call_details.get("model"))

if not model or model == "unknown":
chunk_model = (
AnthropicPassthroughLoggingHandler._extract_model_from_anthropic_chunks(
all_chunks
)
)
if chunk_model:
model = chunk_model

complete_streaming_response = (
AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
all_chunks=all_chunks,
Expand Down Expand Up @@ -468,6 +517,13 @@ def _build_complete_streaming_response_legacy(
# Process each individual event
for event_str in individual_events:
try:
# Skip OpenAI-style [DONE] sentinels some Anthropic-compatible
# providers emit. Match the whole SSE line so a valid chunk whose
# text payload happens to contain "[DONE]" is not dropped.
if any(
line.strip() == "data: [DONE]" for line in event_str.split("\n")
):
continue
transformed_openai_chunk = anthropic_model_response_iterator.convert_str_chunk_to_generic_chunk(
chunk=event_str
)
Expand All @@ -476,6 +532,14 @@ def _build_complete_streaming_response_legacy(

except (StopIteration, StopAsyncIteration):
break
except json.JSONDecodeError:
# Some upstreams emit non-JSON SSE lines; skip them so the
# logging pipeline is not broken by a single bad frame.
verbose_proxy_logger.debug(
"Skipping non-JSON SSE event: %s",
event_str[:200],
)
continue

complete_streaming_response = litellm.stream_chunk_builder(
chunks=all_openai_chunks,
Expand Down
25 changes: 22 additions & 3 deletions litellm/proxy/proxy_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,19 @@ def _build_db_connection_url_params(
pool_timeout: Optional[Union[int, float]],
connect_timeout: Optional[Union[int, float]] = None,
socket_timeout: Optional[Union[int, float]] = None,
disable_prepared_statements: bool = False,
extra_params: Optional[dict] = None,
) -> dict:
"""Build the Prisma DATABASE_URL query params controlling connection pool behavior.

`connect_timeout` / `socket_timeout` map to the Prisma URL params of the same
name (https://www.prisma.io/docs/orm/overview/databases/postgresql) and are
omitted when None so Prisma's defaults apply. `extra_params` is an
untyped passthrough — keys it provides win over the named arguments above,
so it can be used to override any default we set here.
omitted when None so Prisma's defaults apply. `disable_prepared_statements`
sets `pgbouncer=true`, which makes Prisma stop using server-side prepared
statements (pgbouncer transaction-pool compatible; also sidesteps the
"cached plan must not change result type" error during rolling migrations).
`extra_params` is an untyped passthrough — keys it provides win over the
named arguments above, so it can be used to override any default we set here.
"""
params: dict = {
"connection_limit": connection_limit,
Expand All @@ -63,6 +67,8 @@ def _build_db_connection_url_params(
params["connect_timeout"] = connect_timeout
if socket_timeout is not None:
params["socket_timeout"] = socket_timeout
if disable_prepared_statements:
params["pgbouncer"] = "true"
if extra_params:
params.update(extra_params)
return params
Expand Down Expand Up @@ -925,6 +931,7 @@ def run_server( # noqa: PLR0915
db_connection_timeout: Optional[Union[int, float]] = 60
db_connect_timeout: Optional[Union[int, float]] = None
db_socket_timeout: Optional[Union[int, float]] = None
db_disable_prepared_statements: bool = False
db_extra_connection_params: Optional[dict] = None
general_settings = {}
### GET DB TOKEN FOR IAM AUTH ###
Expand Down Expand Up @@ -1045,6 +1052,17 @@ def run_server( # noqa: PLR0915
)
db_connect_timeout = general_settings.get("database_connect_timeout")
db_socket_timeout = general_settings.get("database_socket_timeout")
_disable_prepared_statements = general_settings.get(
"database_disable_prepared_statements", False
)
if isinstance(_disable_prepared_statements, str):
from litellm.secret_managers.main import str_to_bool

db_disable_prepared_statements = (
str_to_bool(_disable_prepared_statements) is True
)
else:
db_disable_prepared_statements = bool(_disable_prepared_statements)
db_extra_connection_params = general_settings.get(
"database_extra_connection_params"
)
Expand Down Expand Up @@ -1092,6 +1110,7 @@ def run_server( # noqa: PLR0915
pool_timeout=db_connection_timeout,
connect_timeout=db_connect_timeout,
socket_timeout=db_socket_timeout,
disable_prepared_statements=db_disable_prepared_statements,
extra_params=db_extra_connection_params,
)
if os.getenv("DATABASE_URL", None) is not None:
Expand Down
Loading
Loading